diff --git a/evaluation/.env-example b/evaluation/.env-example index bab6f679e..cd3943fb6 100644 --- a/evaluation/.env-example +++ b/evaluation/.env-example @@ -1,7 +1,7 @@ # memory process model MODEL="gpt-4o-mini" OPENAI_API_KEY="sk-***REDACTED***" -OPENAI_BASE_URL="http://***.***.***.***:3000/v1" +OPENAI_API_BASE="http://***.***.***.***:3000/v1" # response model diff --git a/evaluation/scripts/PrefEval/pref_eval.py b/evaluation/scripts/PrefEval/pref_eval.py index ec079614d..9da3c9438 100644 --- a/evaluation/scripts/PrefEval/pref_eval.py +++ b/evaluation/scripts/PrefEval/pref_eval.py @@ -17,7 +17,7 @@ load_dotenv() API_KEY = os.getenv("OPENAI_API_KEY") -API_URL = os.getenv("OPENAI_BASE_URL") +API_URL = os.getenv("OPENAI_API_BASE") async def call_gpt4o_mini_async(client: OpenAI, prompt: str) -> str: diff --git a/evaluation/scripts/PrefEval/pref_mem0.py b/evaluation/scripts/PrefEval/pref_mem0.py index 300e0ede3..5b4fed9a5 100644 --- a/evaluation/scripts/PrefEval/pref_mem0.py +++ b/evaluation/scripts/PrefEval/pref_mem0.py @@ -22,7 +22,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") os.environ["MEM0_API_KEY"] = os.getenv("MEM0_API_KEY") diff --git a/evaluation/scripts/PrefEval/pref_memobase.py b/evaluation/scripts/PrefEval/pref_memobase.py index 776642657..03508efcf 100644 --- a/evaluation/scripts/PrefEval/pref_memobase.py +++ b/evaluation/scripts/PrefEval/pref_memobase.py @@ -22,7 +22,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_memos.py b/evaluation/scripts/PrefEval/pref_memos.py index bbe1788b5..05b41492a 100644 --- a/evaluation/scripts/PrefEval/pref_memos.py +++ b/evaluation/scripts/PrefEval/pref_memos.py @@ -23,7 +23,7 @@ load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_memu.py b/evaluation/scripts/PrefEval/pref_memu.py index 00c411eb7..cbeca0702 100644 --- a/evaluation/scripts/PrefEval/pref_memu.py +++ b/evaluation/scripts/PrefEval/pref_memu.py @@ -24,7 +24,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_supermemory.py b/evaluation/scripts/PrefEval/pref_supermemory.py index 7386bc462..dec7ba68c 100644 --- a/evaluation/scripts/PrefEval/pref_supermemory.py +++ b/evaluation/scripts/PrefEval/pref_supermemory.py @@ -22,7 +22,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_zep.py b/evaluation/scripts/PrefEval/pref_zep.py index 8a4d50558..a664d6dec 100644 --- a/evaluation/scripts/PrefEval/pref_zep.py +++ b/evaluation/scripts/PrefEval/pref_zep.py @@ -24,7 +24,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/locomo/locomo_eval.py b/evaluation/scripts/locomo/locomo_eval.py index 6e7dd4083..5a2777423 100644 --- a/evaluation/scripts/locomo/locomo_eval.py +++ b/evaluation/scripts/locomo/locomo_eval.py @@ -305,7 +305,7 @@ async def main(frame, version="default", options=None, num_runs=1, max_workers=4 load_dotenv() oai_client = AsyncOpenAI( - api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL") + api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE") ) with open(response_path) as file: diff --git a/evaluation/scripts/locomo/locomo_openai.py b/evaluation/scripts/locomo/locomo_openai.py index 0b6c52922..06bfb81b8 100644 --- a/evaluation/scripts/locomo/locomo_openai.py +++ b/evaluation/scripts/locomo/locomo_openai.py @@ -1,173 +1,173 @@ -import argparse -import json -import os -import time - -from collections import defaultdict -from multiprocessing.dummy import Pool - -from dotenv import load_dotenv -from openai import OpenAI -from tenacity import retry, stop_after_attempt, wait_random_exponential -from tqdm import tqdm - - -load_dotenv() - -# Retry policy constants -WAIT_MIN = 5 # minimum backoff delay in seconds -WAIT_MAX = 30 # maximum backoff delay in seconds -MAX_TRIES = 10 # maximum number of retry attempts - -WORKERS = 5 # number of parallel worker processes - -ANSWER_PROMPT = """ - You are an intelligent memory assistant tasked with retrieving accurate information from conversation memories. - - # CONTEXT: - You have access to memories from a conversation. These memories contain - timestamped information that may be relevant to answering the question. - - # INSTRUCTIONS: - 1. Carefully analyze all provided memories - 2. Pay special attention to the timestamps to determine the answer - 3. If the question asks about a specific event or fact, look for direct evidence in the memories - 4. If the memories contain contradictory information, prioritize the most recent memory - 5. If there is a question about time references (like "last year", "two months ago", etc.), - calculate the actual date based on the memory timestamp. For example, if a memory from - 4 May 2022 mentions "went to India last year," then the trip occurred in 2021. - 6. Always convert relative time references to specific dates, months, or years. For example, - convert "last year" to "2022" or "two months ago" to "March 2023" based on the memory - timestamp. Ignore the reference while answering the question. - 7. Focus only on the content of the memories. Do not confuse character - names mentioned in memories with the actual users who created those memories. - 8. The answer should be less than 5-6 words. - - # APPROACH (Think step by step): - 1. First, examine all memories that contain information related to the question - 2. Examine the timestamps and content of these memories carefully - 3. Look for explicit mentions of dates, times, locations, or events that answer the question - 4. If the answer requires calculation (e.g., converting relative time references), show your work - 5. Formulate a precise, concise answer based solely on the evidence in the memories - 6. Double-check that your answer directly addresses the question asked - 7. Ensure your final answer is specific and avoids vague time references - - Memories: - - {context} - - Question: {question} - Answer: - """ - - -class OpenAIPredict: - def __init__(self, model="gpt-4o-mini"): - self.model = model - self.openai_client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL") - ) - self.results = defaultdict(list) - - def search_memory(self, idx): - with open(f"openai_memory/{idx}.txt", encoding="utf-8") as file: - memories = file.read().strip().replace("\n\n", "\n") - - return memories, 0 - - def process_question(self, val, idx): - question = val.get("question", "") - answer = val.get("answer", "") - category = val.get("category", -1) - - response, search_memory_time, response_time, context = self.answer_question(idx, question) - - result = { - "question": question, - "answer": response, - "category": category, - "golden_answer": answer, - "search_context": context, - "response_duration_ms": response_time, - "search_duration_ms": search_memory_time, - } - - return result - - @retry( - wait=wait_random_exponential(min=WAIT_MIN, max=WAIT_MAX), - stop=stop_after_attempt(MAX_TRIES), - reraise=True, - ) - def answer_question(self, idx, question): - memories, search_memory_time = self.search_memory(idx) - - answer_prompt = ANSWER_PROMPT.format(context=memories, question=question) - - t1 = time.time() - response = self.openai_client.chat.completions.create( - model=self.model, - messages=[{"role": "system", "content": answer_prompt}], - temperature=0.0, - ) - t2 = time.time() - response_time = (t2 - t1) * 1000 - return response.choices[0].message.content, search_memory_time, response_time, memories - - def process_data_file(self, file_path, output_file_path): - with open(file_path, encoding="utf-8") as f: - data = json.load(f) - - # Function to process each conversation - def process_conversation(item): - idx, conversation = item - results_for_conversation = [] - - # Process each question in the conversation - for question_item in tqdm( - conversation["qa"], desc=f"Processing questions for conversation {idx}", leave=False - ): - if int(question_item.get("category", "")) == 5: - continue - result = self.process_question(question_item, idx) - results_for_conversation.append(result) - - return idx, results_for_conversation - - # Use multiprocessing to process the conversations in parallel - with Pool(processes=WORKERS) as pool: - results = list( - tqdm( - pool.imap(process_conversation, list(enumerate(data))), - total=len(data), - desc="Processing conversations", - ) - ) - - # Reorganize results and store them in self.results - for idx, results_for_conversation in results: - self.results[f"locomo_exp_user_{idx}"] = results_for_conversation - - # Save results to output file - with open(output_file_path, "w") as f: - json.dump(self.results, f, indent=4) - - -def main(version): - os.makedirs(f"results/locomo/openai-{version}/", exist_ok=True) - output_file_path = f"results/locomo/openai-{version}/openai_locomo_responses.json" - openai_predict = OpenAIPredict() - openai_predict.process_data_file("data/locomo/locomo10.json", output_file_path) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument( - "--version", - type=str, - default="default", - help="Version identifier for loading results (e.g., 1010)", - ) - args = parser.parse_args() - version = args.version - main(version) +import argparse +import json +import os +import time + +from collections import defaultdict +from multiprocessing.dummy import Pool + +from dotenv import load_dotenv +from openai import OpenAI +from tenacity import retry, stop_after_attempt, wait_random_exponential +from tqdm import tqdm + + +load_dotenv() + +# Retry policy constants +WAIT_MIN = 5 # minimum backoff delay in seconds +WAIT_MAX = 30 # maximum backoff delay in seconds +MAX_TRIES = 10 # maximum number of retry attempts + +WORKERS = 5 # number of parallel worker processes + +ANSWER_PROMPT = """ + You are an intelligent memory assistant tasked with retrieving accurate information from conversation memories. + + # CONTEXT: + You have access to memories from a conversation. These memories contain + timestamped information that may be relevant to answering the question. + + # INSTRUCTIONS: + 1. Carefully analyze all provided memories + 2. Pay special attention to the timestamps to determine the answer + 3. If the question asks about a specific event or fact, look for direct evidence in the memories + 4. If the memories contain contradictory information, prioritize the most recent memory + 5. If there is a question about time references (like "last year", "two months ago", etc.), + calculate the actual date based on the memory timestamp. For example, if a memory from + 4 May 2022 mentions "went to India last year," then the trip occurred in 2021. + 6. Always convert relative time references to specific dates, months, or years. For example, + convert "last year" to "2022" or "two months ago" to "March 2023" based on the memory + timestamp. Ignore the reference while answering the question. + 7. Focus only on the content of the memories. Do not confuse character + names mentioned in memories with the actual users who created those memories. + 8. The answer should be less than 5-6 words. + + # APPROACH (Think step by step): + 1. First, examine all memories that contain information related to the question + 2. Examine the timestamps and content of these memories carefully + 3. Look for explicit mentions of dates, times, locations, or events that answer the question + 4. If the answer requires calculation (e.g., converting relative time references), show your work + 5. Formulate a precise, concise answer based solely on the evidence in the memories + 6. Double-check that your answer directly addresses the question asked + 7. Ensure your final answer is specific and avoids vague time references + + Memories: + + {context} + + Question: {question} + Answer: + """ + + +class OpenAIPredict: + def __init__(self, model="gpt-4o-mini"): + self.model = model + self.openai_client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE") + ) + self.results = defaultdict(list) + + def search_memory(self, idx): + with open(f"openai_memory/{idx}.txt", encoding="utf-8") as file: + memories = file.read().strip().replace("\n\n", "\n") + + return memories, 0 + + def process_question(self, val, idx): + question = val.get("question", "") + answer = val.get("answer", "") + category = val.get("category", -1) + + response, search_memory_time, response_time, context = self.answer_question(idx, question) + + result = { + "question": question, + "answer": response, + "category": category, + "golden_answer": answer, + "search_context": context, + "response_duration_ms": response_time, + "search_duration_ms": search_memory_time, + } + + return result + + @retry( + wait=wait_random_exponential(min=WAIT_MIN, max=WAIT_MAX), + stop=stop_after_attempt(MAX_TRIES), + reraise=True, + ) + def answer_question(self, idx, question): + memories, search_memory_time = self.search_memory(idx) + + answer_prompt = ANSWER_PROMPT.format(context=memories, question=question) + + t1 = time.time() + response = self.openai_client.chat.completions.create( + model=self.model, + messages=[{"role": "system", "content": answer_prompt}], + temperature=0.0, + ) + t2 = time.time() + response_time = (t2 - t1) * 1000 + return response.choices[0].message.content, search_memory_time, response_time, memories + + def process_data_file(self, file_path, output_file_path): + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + + # Function to process each conversation + def process_conversation(item): + idx, conversation = item + results_for_conversation = [] + + # Process each question in the conversation + for question_item in tqdm( + conversation["qa"], desc=f"Processing questions for conversation {idx}", leave=False + ): + if int(question_item.get("category", "")) == 5: + continue + result = self.process_question(question_item, idx) + results_for_conversation.append(result) + + return idx, results_for_conversation + + # Use multiprocessing to process the conversations in parallel + with Pool(processes=WORKERS) as pool: + results = list( + tqdm( + pool.imap(process_conversation, list(enumerate(data))), + total=len(data), + desc="Processing conversations", + ) + ) + + # Reorganize results and store them in self.results + for idx, results_for_conversation in results: + self.results[f"locomo_exp_user_{idx}"] = results_for_conversation + + # Save results to output file + with open(output_file_path, "w") as f: + json.dump(self.results, f, indent=4) + + +def main(version): + os.makedirs(f"results/locomo/openai-{version}/", exist_ok=True) + output_file_path = f"results/locomo/openai-{version}/openai_locomo_responses.json" + openai_predict = OpenAIPredict() + openai_predict.process_data_file("data/locomo/locomo10.json", output_file_path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "--version", + type=str, + default="default", + help="Version identifier for loading results (e.g., 1010)", + ) + args = parser.parse_args() + version = args.version + main(version) diff --git a/evaluation/scripts/longmemeval/lme_eval.py b/evaluation/scripts/longmemeval/lme_eval.py index 20681ac2c..d85eec1a1 100644 --- a/evaluation/scripts/longmemeval/lme_eval.py +++ b/evaluation/scripts/longmemeval/lme_eval.py @@ -277,7 +277,7 @@ async def main(frame, version, nlp_options, num_runs=3, num_workers=5): print(f"Starting evaluation for {frame} version {version}...") load_dotenv() - oai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) + oai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE")) response_path = f"results/lme/{frame}-{version}/{frame}_lme_responses.json" judged_path = f"results/lme/{frame}-{version}/{frame}_lme_judged.json" diff --git a/evaluation/scripts/longmemeval/lme_rag.py b/evaluation/scripts/longmemeval/lme_rag.py index 523102e11..faccfeb7a 100644 --- a/evaluation/scripts/longmemeval/lme_rag.py +++ b/evaluation/scripts/longmemeval/lme_rag.py @@ -23,7 +23,7 @@ load_dotenv() -openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE")) class RAGFullContext(RAGManager): diff --git a/examples/basic_modules/llm.py b/examples/basic_modules/llm.py index 3fd7352c7..7d05fb2de 100644 --- a/examples/basic_modules/llm.py +++ b/examples/basic_modules/llm.py @@ -1,3 +1,5 @@ +import os + from memos.configs.llm import LLMConfigFactory, OllamaLLMConfig from memos.llms.factory import LLMFactory from memos.llms.ollama import OllamaLLM @@ -51,36 +53,43 @@ # Scenario 3: Using LLMFactory with OpenAI Backend # Prerequisites: -# 1. You need a valid OpenAI API key to run this scenario. -# 2. Replace 'sk-xxxx' with your actual API key below. - - -config = LLMConfigFactory.model_validate( - { - "backend": "openai", - "config": { - "model_name_or_path": "gpt-4.1-nano", - "temperature": 0.8, - "max_tokens": 1024, - "top_p": 0.9, - "top_k": 50, - "api_key": "sk-xxxx", - "api_base": "https://api.openai.com/v1", - }, - } -) -llm = LLMFactory.from_config(config) -messages = [ - {"role": "user", "content": "Hello, who are you"}, -] -response = llm.generate(messages) -print("Scenario 3:", response) -print("==" * 20) - -print("Scenario 3:\n") -for chunk in llm.generate_stream(messages): - print(chunk, end="") -print("==" * 20) +# 1. export OPENAI_API_KEY="sk-..." (never commit real keys to git) +# 2. Optional: export OPENAI_API_BASE="https://api.openai.com/v1" or your compatible endpoint + +_openai_key = os.getenv("OPENAI_API_KEY") +_openai_base = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1") + +if _openai_key: + config = LLMConfigFactory.model_validate( + { + "backend": "openai", + "config": { + "model_name_or_path": "gpt-4.1-nano", + "temperature": 0.8, + "max_tokens": 1024, + "top_p": 0.9, + "top_k": 50, + "api_key": _openai_key, + "api_base": _openai_base.rstrip("/"), + }, + } + ) + llm = LLMFactory.from_config(config) + messages = [ + {"role": "user", "content": "Hello, who are you"}, + ] + response = llm.generate(messages) + print("Scenario 3:", response) + print("==" * 20) + + print("Scenario 3 (stream):\n") + for chunk in llm.generate_stream(messages): + print(chunk, end="") + print("==" * 20) +else: + print( + "Scenario 3 skipped: set OPENAI_API_KEY (and optionally OPENAI_API_BASE) to run OpenAI example." + ) # Scenario 4: Using LLMFactory with Huggingface Models diff --git a/examples/core_memories/general_textual_memory.py b/examples/core_memories/general_textual_memory.py index 007736a6e..b13e077c3 100644 --- a/examples/core_memories/general_textual_memory.py +++ b/examples/core_memories/general_textual_memory.py @@ -16,10 +16,7 @@ "config": { "model_name_or_path": "gpt-4o-mini", "api_key": os.environ.get("OPENAI_API_KEY"), - "api_base": os.environ.get( - "OPENAI_BASE_URL", - os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), + "api_base": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), "temperature": 0.0, "remove_think_prefix": True, "max_tokens": 8192, diff --git a/examples/core_memories/naive_textual_memory.py b/examples/core_memories/naive_textual_memory.py index 1e7901e0f..149d3efe0 100644 --- a/examples/core_memories/naive_textual_memory.py +++ b/examples/core_memories/naive_textual_memory.py @@ -15,10 +15,7 @@ "config": { "model_name_or_path": "gpt-4o-mini", "api_key": os.environ.get("OPENAI_API_KEY"), - "api_base": os.environ.get( - "OPENAI_BASE_URL", - os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), + "api_base": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), "temperature": 0.0, "remove_think_prefix": True, }, diff --git a/src/memos/api/config.py b/src/memos/api/config.py index 4d2029969..b3e45dfb2 100644 --- a/src/memos/api/config.py +++ b/src/memos/api/config.py @@ -260,13 +260,56 @@ def _auth_headers(): class APIConfig: """Centralized configuration management for MemOS APIs.""" + _OPENAI_API_BASE = "https://api.openai.com/v1" + _QWEN_API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1" + + @staticmethod + def _is_qwen_model(model_name: str) -> bool: + normalized_model = model_name.strip().lower() + return normalized_model.startswith("qwen") or "/qwen" in normalized_model + @staticmethod - def _preference_extractor_extra_body(model_name: str) -> dict[str, Any] | None: + def _qwen_flash_extra_body(model_name: str) -> dict[str, Any] | None: normalized_model = model_name.strip().lower() if normalized_model.startswith(("qwen3.5", "qwen3.6")): return {"enable_thinking": False} return None + @staticmethod + def _build_provider_llm_config( + model_name: str, + *, + temperature: float = 0.6, + max_tokens: int = 8000, + top_p: float = 0.95, + top_k: int = 20, + remove_think_prefix: bool = True, + ) -> dict[str, Any]: + """Build task LLM config from model name plus shared provider credentials. + + MEMRADER_MODEL remains a dedicated fine-tuned extractor. Other task-specific + model knobs choose only the model; endpoint credentials come from either + QWEN_* or OPENAI_* according to the model family. + """ + is_qwen = APIConfig._is_qwen_model(model_name) + config = { + "model_name_or_path": model_name, + "temperature": temperature, + "max_tokens": max_tokens, + "top_p": top_p, + "top_k": top_k, + "remove_think_prefix": remove_think_prefix, + "api_key": os.getenv("QWEN_API_KEY" if is_qwen else "OPENAI_API_KEY", "EMPTY"), + "api_base": os.getenv( + "QWEN_API_BASE" if is_qwen else "OPENAI_API_BASE", + APIConfig._QWEN_API_BASE if is_qwen else APIConfig._OPENAI_API_BASE, + ), + } + extra_body = APIConfig._qwen_flash_extra_body(model_name) if is_qwen else None + if extra_body is not None: + config["extra_body"] = extra_body + return {"backend": "qwen" if is_qwen else "openai", "config": config} + @staticmethod def get_profile_memory_reserved_top_k() -> int: """Get profile-memory MMR reserve count. @@ -293,7 +336,7 @@ def get_openai_config() -> dict[str, Any]: "top_k": int(os.getenv("MOS_TOP_K", "50")), "remove_think_prefix": True, "api_key": os.getenv("OPENAI_API_KEY", "your-api-key-here"), - "api_base": os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), + "api_base": os.getenv("OPENAI_API_BASE", APIConfig._OPENAI_API_BASE), } @staticmethod @@ -379,15 +422,11 @@ def get_memreader_config() -> dict[str, Any]: general_model = os.getenv("MEMREADER_GENERAL_MODEL") enable_backup = os.getenv("MEMREADER_ENABLE_BACKUP", "false").lower() == "true" if general_model and enable_backup: + backup_config = APIConfig._build_provider_llm_config(general_model)["config"] config["backup_client"] = True config["backup_model_name_or_path"] = general_model - config["backup_api_key"] = os.getenv( - "MEMREADER_GENERAL_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ) - config["backup_api_base"] = os.getenv( - "MEMREADER_GENERAL_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ) + config["backup_api_key"] = backup_config["api_key"] + config["backup_api_base"] = backup_config["api_base"] return {"backend": "openai", "config": config} @@ -395,21 +434,13 @@ def get_memreader_config() -> dict[str, Any]: def get_qwen_llm_config() -> dict[str, Any] | None: if not os.getenv("QWEN_API_KEY"): return None - return { - "backend": "qwen", - "config": { - "model_name_or_path": os.getenv("QWEN_MODEL", "qwen-flash"), - "temperature": float(os.getenv("QWEN_TEMPERATURE", "0.8")), - "max_tokens": int(os.getenv("QWEN_MAX_TOKENS", "8000")), - "top_p": float(os.getenv("QWEN_TOP_P", "0.9")), - "top_k": int(os.getenv("QWEN_TOP_K", "50")), - "remove_think_prefix": os.getenv("QWEN_REMOVE_THINK_PREFIX", "true").lower() - == "true", - "api_key": os.getenv("QWEN_API_KEY", ""), - "api_base": os.getenv("QWEN_API_BASE", ""), - "model_schema": os.getenv("QWEN_MODEL_SCHEMA", "memos.configs.llm.QwenLLMConfig"), - }, - } + return APIConfig._build_provider_llm_config( + os.getenv("QWEN_MODEL", "qwen-flash"), + temperature=0.8, + max_tokens=8000, + top_p=0.9, + top_k=50, + ) @staticmethod def get_memreader_general_llm_config() -> dict[str, Any]: @@ -428,24 +459,7 @@ def get_memreader_general_llm_config() -> dict[str, Any]: # Check if specific general model is configured general_model = os.getenv("MEMREADER_GENERAL_MODEL") if general_model: - return { - "backend": os.getenv("MEMREADER_GENERAL_BACKEND", "openai"), - "config": { - "model_name_or_path": general_model, - "temperature": 0.6, - "max_tokens": int(os.getenv("MEMREADER_GENERAL_MAX_TOKENS", "8000")), - "top_p": 0.95, - "top_k": 20, - "api_key": os.getenv( - "MEMREADER_GENERAL_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ), - "api_base": os.getenv( - "MEMREADER_GENERAL_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), - "remove_think_prefix": True, - }, - } + return APIConfig._build_provider_llm_config(general_model) # Fallback to memreader config (same behavior as before for users who don't customize) return APIConfig.get_memreader_config() @@ -460,24 +474,7 @@ def get_image_parser_llm_config() -> dict[str, Any]: """ image_model = os.getenv("IMAGE_PARSER_MODEL") if image_model: - return { - "backend": os.getenv("IMAGE_PARSER_BACKEND", "openai"), - "config": { - "model_name_or_path": image_model, - "temperature": 0.6, - "max_tokens": int(os.getenv("IMAGE_PARSER_MAX_TOKENS", "4096")), - "top_p": 0.95, - "top_k": 20, - "api_key": os.getenv( - "IMAGE_PARSER_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ), - "api_base": os.getenv( - "IMAGE_PARSER_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), - "remove_think_prefix": True, - }, - } + return APIConfig._build_provider_llm_config(image_model, max_tokens=4096) # Fallback to general_llm config (which itself falls back to OpenAI) return APIConfig.get_memreader_general_llm_config() @@ -491,31 +488,23 @@ def get_preference_extractor_llm_config() -> dict[str, Any]: """ pref_model = os.getenv("PREFERENCE_EXTRACTOR_MODEL") if pref_model: - extra_body = APIConfig._preference_extractor_extra_body(pref_model) - config = { - "model_name_or_path": pref_model, - "temperature": 0.6, - "max_tokens": int(os.getenv("PREFERENCE_EXTRACTOR_MAX_TOKENS", "8000")), - "top_p": 0.95, - "top_k": 20, - "api_key": os.getenv( - "PREFERENCE_EXTRACTOR_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ), - "api_base": os.getenv( - "PREFERENCE_EXTRACTOR_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), - "remove_think_prefix": True, - } - if extra_body is not None: - config["extra_body"] = extra_body - return { - "backend": os.getenv("PREFERENCE_EXTRACTOR_BACKEND", "openai"), - "config": config, - } + return APIConfig._build_provider_llm_config(pref_model) # Fallback to general_llm config (which itself falls back to OpenAI) return APIConfig.get_memreader_general_llm_config() + @staticmethod + def get_feedback_llm_config() -> dict[str, Any]: + """Get LLM configuration for feedback processing. + + Used for: feedback judgement, semantic add/update decisions, and update safety review. + + Fallback chain: FEEDBACK_MODEL -> general_llm -> memreader config. + """ + feedback_model = os.getenv("FEEDBACK_MODEL") + if feedback_model: + return APIConfig._build_provider_llm_config(feedback_model, temperature=0.8) + return APIConfig.get_memreader_general_llm_config() + @staticmethod def get_activation_vllm_config() -> dict[str, Any]: """Get Ollama configuration.""" diff --git a/src/memos/api/handlers/__init__.py b/src/memos/api/handlers/__init__.py index bd4c9f4b0..e6001c32a 100644 --- a/src/memos/api/handlers/__init__.py +++ b/src/memos/api/handlers/__init__.py @@ -20,6 +20,7 @@ from memos.api.handlers.component_init import init_server from memos.api.handlers.config_builders import ( build_embedder_config, + build_feedback_llm_config, build_graph_db_config, build_internet_retriever_config, build_llm_config, @@ -39,6 +40,7 @@ __all__ = [ "add_handler", "build_embedder_config", + "build_feedback_llm_config", "build_graph_db_config", "build_internet_retriever_config", "build_llm_config", diff --git a/src/memos/api/handlers/component_init.py b/src/memos/api/handlers/component_init.py index b9c209e61..d4595a19f 100644 --- a/src/memos/api/handlers/component_init.py +++ b/src/memos/api/handlers/component_init.py @@ -13,6 +13,7 @@ from memos.api.handlers.config_builders import ( build_chat_llm_config, build_embedder_config, + build_feedback_llm_config, build_feedback_reranker_config, build_graph_db_config, build_internet_retriever_config, @@ -156,6 +157,7 @@ def init_server() -> dict[str, Any]: # Build component configurations graph_db_config = build_graph_db_config() llm_config = build_llm_config() + feedback_llm_config = build_feedback_llm_config() chat_llm_config = build_chat_llm_config() playground_chat_llm_config = build_chat_llm_config("PLAYGROUND_CHAT_MODEL_LIST") embedder_config = build_embedder_config() @@ -170,6 +172,7 @@ def init_server() -> dict[str, Any]: # Create component instances graph_db = GraphStoreFactory.from_config(graph_db_config) llm = LLMFactory.from_config(llm_config) + feedback_llm = LLMFactory.from_config(feedback_llm_config) chat_llms = ( _init_chat_llms(chat_llm_config) if os.getenv("ENABLE_CHAT_API", "false") == "true" @@ -260,7 +263,7 @@ def init_server() -> dict[str, Any]: # Initialize feedback server feedback_server = SimpleMemFeedback( - llm=llm, + llm=feedback_llm, embedder=embedder, graph_store=graph_db, memory_manager=memory_manager, diff --git a/src/memos/api/handlers/config_builders.py b/src/memos/api/handlers/config_builders.py index 0a083e284..38702f850 100644 --- a/src/memos/api/handlers/config_builders.py +++ b/src/memos/api/handlers/config_builders.py @@ -157,6 +157,16 @@ def build_feedback_reranker_config() -> dict[str, Any]: return RerankerConfigFactory.model_validate(APIConfig.get_feedback_reranker_config()) +def build_feedback_llm_config() -> dict[str, Any]: + """ + Build feedback LLM configuration. + + Returns: + Validated feedback LLM configuration dictionary + """ + return LLMConfigFactory.model_validate(APIConfig.get_feedback_llm_config()) + + def build_internet_retriever_config() -> dict[str, Any]: """ Build internet retriever configuration. diff --git a/src/memos/configs/mem_scheduler.py b/src/memos/configs/mem_scheduler.py index f76ddecc4..6314ef596 100644 --- a/src/memos/configs/mem_scheduler.py +++ b/src/memos/configs/mem_scheduler.py @@ -212,6 +212,14 @@ class OpenAIConfig(BaseConfig, DictConversionMixin, EnvConfigMixin): base_url: str = Field(default="", description="Base URL for API endpoint") default_model: str = Field(default="", description="Default model to use") + @classmethod + def from_env(cls) -> "OpenAIConfig": + return cls( + api_key=os.getenv("OPENAI_API_KEY", ""), + base_url=os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), + default_model=os.getenv("MEMSCHEDULER_OPENAI_DEFAULT_MODEL", "gpt-4o-mini"), + ) + class AuthConfig(BaseConfig, DictConversionMixin): rabbitmq: RabbitMQConfig | None = None @@ -339,10 +347,16 @@ def from_local_env(cls) -> "AuthConfig": except (ValueError, Exception) as e: logger.warning(f"Failed to initialize RabbitMQ config from environment: {e}") - # Try to initialize OpenAI config - check if any OpenAI env vars exist + # Try to initialize OpenAI config - shared credentials plus scheduler model override. try: - openai_prefix = OpenAIConfig.get_env_prefix() - has_openai_env = any(key.startswith(openai_prefix) for key in os.environ) + has_openai_env = any( + key in os.environ + for key in [ + "OPENAI_API_KEY", + "OPENAI_API_BASE", + "MEMSCHEDULER_OPENAI_DEFAULT_MODEL", + ] + ) if has_openai_env: openai_config = OpenAIConfig.from_env() logger.info("Successfully initialized OpenAI configuration") @@ -375,7 +389,7 @@ def set_openai_config_to_environment(self): # Set environment variables only if openai config is available if self.openai is not None: os.environ["OPENAI_API_KEY"] = self.openai.api_key - os.environ["OPENAI_BASE_URL"] = self.openai.base_url + os.environ["OPENAI_API_BASE"] = self.openai.base_url os.environ["MODEL"] = self.openai.default_model else: logger = logging.getLogger(__name__) diff --git a/src/memos/configs/parser.py b/src/memos/configs/parser.py index 22c1fe1b5..451954240 100644 --- a/src/memos/configs/parser.py +++ b/src/memos/configs/parser.py @@ -17,7 +17,7 @@ class ParserConfigFactory(BaseConfig): """Factory class for creating Parser configurations.""" backend: str = Field(..., description="Backend for parser") - config: dict[str, Any] = Field(..., description="Configuration for the parser backend") + config: BaseParserConfig = Field(..., description="Configuration for the parser backend") backend_to_class: ClassVar[dict[str, Any]] = { "markitdown": MarkItDownParserConfig, @@ -31,8 +31,23 @@ def validate_backend(cls, backend: str) -> str: raise ValueError(f"Invalid backend: {backend}") return backend - @model_validator(mode="after") - def create_config(self) -> "ParserConfigFactory": - config_class = self.backend_to_class[self.backend] - self.config = config_class(**self.config) - return self + @model_validator(mode="before") + @classmethod + def create_config(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + if "backend" not in data or "config" not in data: + return data + + config_class = cls.backend_to_class.get(data["backend"]) + if config_class is None: + return data + + config = data.get("config") + if isinstance(config, config_class): + return data + + data = data.copy() + data["config"] = config_class.model_validate(config) + return data diff --git a/src/memos/embedders/universal_api.py b/src/memos/embedders/universal_api.py index c71ed6b5a..66ed4e62c 100644 --- a/src/memos/embedders/universal_api.py +++ b/src/memos/embedders/universal_api.py @@ -14,6 +14,19 @@ logger = get_logger(__name__) +def _embedding_log_extra_args(embedder: "UniversalAPIEmbedder", texts: list[str] | str) -> dict: + text_items = [texts] if isinstance(texts, str) else texts + return { + "model_name_or_path": getattr( + embedder.config, "model_name_or_path", "text-embedding-3-large" + ), + "backup_model_name_or_path": getattr(embedder.config, "backup_model_name_or_path", None), + "use_backup_client": getattr(embedder, "use_backup_client", False), + "text_len": len(text_items), + "text_content": text_items, + } + + def _sanitize_unicode(text: str) -> str: """ Remove Unicode surrogates and other problematic characters. @@ -60,11 +73,7 @@ def __init__(self, config: UniversalAPIEmbedderConfig): @timed_with_status( log_prefix="model_timed_embedding", - log_extra_args=lambda self, texts: { - "model_name_or_path": "text-embedding-3-large", - "text_len": len(texts), - "text_content": texts, - }, + log_extra_args=_embedding_log_extra_args, ) def embed(self, texts: list[str]) -> list[list[float]]: if isinstance(texts, str): diff --git a/src/memos/hello_world.py b/src/memos/hello_world.py index 924fc746f..7c1a9defc 100644 --- a/src/memos/hello_world.py +++ b/src/memos/hello_world.py @@ -72,7 +72,7 @@ def memos_chentang_hello_world(user_id: str = "locomo_exp_user_1", version: str "temperature": 0, "max_tokens": 8192, "api_key": os.getenv("OPENAI_API_KEY"), - "api_base": os.getenv("OPENAI_BASE_URL"), + "api_base": os.environ.get("OPENAI_API_BASE") or "https://api.openai.com/v1", }, }, "vector_db": { diff --git a/src/memos/mem_feedback/feedback.py b/src/memos/mem_feedback/feedback.py index c99cda434..28f495afb 100644 --- a/src/memos/mem_feedback/feedback.py +++ b/src/memos/mem_feedback/feedback.py @@ -859,9 +859,15 @@ def correct_item(data): if not should_keep_update(data["text"], data["old_memory"]): logger.warning( - f"[0107 Feedback Core: correct_item] Due to the excessive proportion of changes, skip update: {data}" + "[0107 Feedback Core: correct_item] Due to the excessive proportion " + "of changes, downgrade update to add: %s", + data, ) - return None + return { + "operation": "ADD", + "text": data["text"], + "_downgraded_from_update": True, + } # id dehallucination original_id = data["id"] @@ -892,11 +898,14 @@ def correct_item(data): add_texts = [] llm_operations = [] for item in dehalluded_operations: - if item["operation"].lower() == "add" and "text" in item and item["text"]: - if item["text"] in add_texts: + if item["operation"].lower() == "add": + add_text = item.get("text") + if not add_text: + continue + if add_text in add_texts: continue llm_operations.append(item) - add_texts.append(item["text"]) + add_texts.append(add_text) elif item["operation"].lower() == "update": llm_operations.append(item) logger.info( @@ -907,10 +916,15 @@ def correct_item(data): has_update = any(item.get("operation").lower() == "update" for item in llm_operations) if has_update: filtered_items = [ - item for item in llm_operations if item.get("operation").lower() == "add" + item + for item in llm_operations + if item.get("operation").lower() == "add" + and not item.get("_downgraded_from_update") ] update_items = [ - item for item in llm_operations if item.get("operation").lower() != "add" + item + for item in llm_operations + if item.get("operation").lower() != "add" or item.get("_downgraded_from_update") ] if filtered_items: logger.info( diff --git a/src/memos/mem_reader/multi_modal_struct.py b/src/memos/mem_reader/multi_modal_struct.py index c962ff528..8a7bc2da1 100644 --- a/src/memos/mem_reader/multi_modal_struct.py +++ b/src/memos/mem_reader/multi_modal_struct.py @@ -61,6 +61,7 @@ def __init__(self, config: MultiModalStructMemReaderConfig): simple_config = SimpleStructMemReaderConfig(**config_dict) super().__init__(simple_config) + self.name = "MultiModalStructMemReader" self.memory_version_switch = getattr(config, "memory_version_switch", "off") # Image parser LLM (requires vision model) @@ -377,7 +378,7 @@ def _build_window_from_items( if aggregated_file_ids: extra_kwargs["file_ids"] = aggregated_file_ids - # Propagate manager_user_id and project_id from constituent items + # Propagate manager_user_id, project_id, and related_id from constituent items for item in items: metadata = getattr(item, "metadata", None) if metadata is not None: diff --git a/src/memos/mem_scheduler/analyzer/eval_analyzer.py b/src/memos/mem_scheduler/analyzer/eval_analyzer.py index 49a382ce6..4125e333b 100644 --- a/src/memos/mem_scheduler/analyzer/eval_analyzer.py +++ b/src/memos/mem_scheduler/analyzer/eval_analyzer.py @@ -36,7 +36,7 @@ def __init__( self, openai_api_key: str | None = None, openai_base_url: str | None = None, - openai_model: str = "gpt-4o-mini", + openai_model: str | None = None, output_dir: str = "./tmp/eval_analyzer", ): """ @@ -53,8 +53,8 @@ def __init__( # Initialize OpenAI client self.openai_client = OpenAI( - api_key=openai_api_key or os.getenv("MEMSCHEDULER_OPENAI_API_KEY"), - base_url=openai_base_url or os.getenv("MEMSCHEDULER_OPENAI_BASE_URL"), + api_key=openai_api_key or os.getenv("OPENAI_API_KEY"), + base_url=openai_base_url or os.getenv("OPENAI_API_BASE"), ) self.openai_model = openai_model or os.getenv( "MEMSCHEDULER_OPENAI_DEFAULT_MODEL", "gpt-4o-mini" diff --git a/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py b/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py index 05a50d49c..97ae0c8f3 100644 --- a/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py +++ b/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py @@ -95,6 +95,16 @@ def build_llm_config() -> dict[str, Any]: ) +def build_feedback_llm_config() -> dict[str, Any]: + """ + Build feedback LLM configuration. + + Returns: + Validated feedback LLM configuration dictionary + """ + return LLMConfigFactory.model_validate(APIConfig.get_feedback_llm_config()) + + def build_chat_llm_config() -> list[dict[str, Any]]: """ Build chat LLM configuration. @@ -257,6 +267,7 @@ def init_components() -> dict[str, Any]: # Build component configurations graph_db_config = build_graph_db_config() llm_config = build_llm_config() + feedback_llm_config = build_feedback_llm_config() embedder_config = build_embedder_config() nli_client_config = build_nli_client_config() mem_reader_config = build_mem_reader_config() @@ -269,6 +280,7 @@ def init_components() -> dict[str, Any]: # Create component instances graph_db = GraphStoreFactory.from_config(graph_db_config) llm = LLMFactory.from_config(llm_config) + feedback_llm = LLMFactory.from_config(feedback_llm_config) embedder = EmbedderFactory.from_config(embedder_config) plugin_manager.discover() @@ -337,7 +349,7 @@ def init_components() -> dict[str, Any]: ) # Initialize feedback server feedback_server = SimpleMemFeedback( - llm=llm, + llm=feedback_llm, embedder=embedder, graph_store=graph_db, memory_manager=memory_manager, diff --git a/src/memos/templates/mem_feedback_prompts.py b/src/memos/templates/mem_feedback_prompts.py index dd30c4f92..5b5073231 100644 --- a/src/memos/templates/mem_feedback_prompts.py +++ b/src/memos/templates/mem_feedback_prompts.py @@ -679,6 +679,8 @@ **Batch Assessment Rules**: - Independently assess each entry in the list and record the evaluation results +- The output must include only `id`, `reason`, and `judgement` for each entry. +- Do not repeat or return the original `text` or `old_memory` fields. **Key Decision Rules**: 1. If the core entities of old and new texts are different → Set `judgement` to "INVALID" (completely invalid) @@ -690,8 +692,7 @@ "operations_judgement": [ {{ "id": "...", - "text": "...", - "old_memory": "...", + "reason": "Briefly explain why this UPDATE is approved or rejected.", "judgement": "INVALID" | "NONE" | "UPDATE_APPROVED" }}, ... @@ -722,14 +723,12 @@ "operations_judgement": [ {{ "id": "275a", - "text": "On December 22, 2025 at 6:58 AM UTC, the user mentioned that Mission Terra is from Germany.", - "old_memory": "On December 13, 2025 at 4:02 PM UTC, the user mentioned that Mission Terra is a French national.", + "reason": "Both memories describe Mission Terra's nationality, and the new statement directly corrects the previous one.", "judgement": "UPDATE_APPROVED" }}, {{ "id": "88a4", - "text": "On December 22, 2025 at 6:58 AM UTC, the user mentioned that Mission Terra is from Germany.", - "old_memory": "On December 22, 2025 at 6:52 AM UTC, the user confirmed that Gladys Liu is an Italian citizen.", + "reason": "The new statement is about Mission Terra, while the old memory is about Gladys Liu.", "judgement": "INVALID" }} ] @@ -757,6 +756,8 @@ **批量评估规则**: - 对列表中的每个条目独立评估,记录评估结果 +- 每个评估结果只允许返回`id`、`reason`、`judgement`。 +- 不要复述或返回原始`text`和`old_memory`字段。 **关键决策规则**: 1. 如果新旧文本核心实体不同 → `judgement`置为"INVALID"(完全无效) @@ -770,8 +771,7 @@ // 评估后的完整operations列表 {{ "id": "...", - "text": "...", - "old_memory": "...", + "reason": "简要说明该UPDATE通过或拒绝的原因", "judgement": "INVALID" | "NONE" | "UPDATE_APPROVED" }}, ... @@ -802,14 +802,12 @@ "operations_judgement": [ {{ "id": "275a", - "text": "2025年12月22日 UTC 时间6:58,用户提到Mission Terra 来自德国。", - "old_memory": "2025年12月13日 UTC 时间16:02,用户提及 Mission Terra 是法国国籍。", + "reason": "新旧记忆都描述Mission Terra的国籍,新事实直接修正了旧事实。", "judgement": "UPDATE_APPROVED" }}, {{ "id": "88a4", - "text": "2025年12月22日 UTC 时间6:58,用户提到Mission Terra 来自德国。", - "old_memory": "2025年12月22日 UTC 时间6:52,用户确认 Gladys Liu 是意大利公民。", + "reason": "新事实描述Mission Terra,而旧记忆描述Gladys Liu,核心实体不同。", "judgement": "INVALID" }} ] diff --git a/tests/api/test_llm_provider_config.py b/tests/api/test_llm_provider_config.py new file mode 100644 index 000000000..61292c438 --- /dev/null +++ b/tests/api/test_llm_provider_config.py @@ -0,0 +1,100 @@ +from memos.api.config import APIConfig + + +def test_task_qwen_model_uses_qwen_provider_env(monkeypatch): + monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "qwen3.6-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + + config = APIConfig.get_preference_extractor_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["model_name_or_path"] == "qwen3.6-flash" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + assert config["config"]["extra_body"] == {"enable_thinking": False} + + +def test_task_openai_model_uses_openai_provider_env(monkeypatch): + monkeypatch.setenv("IMAGE_PARSER_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + + config = APIConfig.get_image_parser_llm_config() + + assert config["backend"] == "openai" + assert config["config"]["model_name_or_path"] == "gpt-4.1-mini" + assert config["config"]["api_key"] == "openai-key" + assert config["config"]["api_base"] == "https://openai.example/v1" + + +def test_qwen_llm_only_uses_model_and_provider_endpoint_env(monkeypatch): + monkeypatch.setenv("QWEN_MODEL", "qwen-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("QWEN_TEMPERATURE", "1.9") + monkeypatch.setenv("QWEN_MAX_TOKENS", "123") + monkeypatch.setenv("QWEN_TOP_P", "0.1") + monkeypatch.setenv("QWEN_TOP_K", "3") + monkeypatch.setenv("QWEN_REMOVE_THINK_PREFIX", "false") + + config = APIConfig.get_qwen_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["model_name_or_path"] == "qwen-flash" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + assert config["config"]["temperature"] == 0.8 + assert config["config"]["max_tokens"] == 8000 + assert config["config"]["top_p"] == 0.9 + assert config["config"]["top_k"] == 50 + assert config["config"]["remove_think_prefix"] is True + + +def test_feedback_model_ignores_task_scoped_endpoint_env(monkeypatch): + monkeypatch.setenv("FEEDBACK_MODEL", "qwen3.6-flash") + monkeypatch.setenv("FEEDBACK_API_KEY", "legacy-feedback-key") + monkeypatch.setenv("FEEDBACK_API_BASE", "https://legacy-feedback.example/v1") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + + config = APIConfig.get_feedback_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + + +def test_memreader_general_model_only_needs_model_name_and_provider_env(monkeypatch): + monkeypatch.setenv("MEMREADER_GENERAL_MODEL", "qwen-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.delenv("MEMREADER_GENERAL_API_KEY", raising=False) + monkeypatch.delenv("MEMREADER_GENERAL_API_BASE", raising=False) + + config = APIConfig.get_memreader_general_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["model_name_or_path"] == "qwen-flash" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + + +def test_memreader_backup_uses_provider_env_for_general_model(monkeypatch): + monkeypatch.setenv("MEMREADER_ENABLE_BACKUP", "true") + monkeypatch.setenv("MEMREADER_GENERAL_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + monkeypatch.setenv("MEMREADER_GENERAL_API_KEY", "legacy-general-key") + monkeypatch.setenv("MEMREADER_GENERAL_API_BASE", "https://legacy-general.example/v1") + + config = APIConfig.get_memreader_config() + + assert config["config"]["backup_client"] is True + assert config["config"]["backup_model_name_or_path"] == "gpt-4.1-mini" + assert config["config"]["backup_api_key"] == "openai-key" + assert config["config"]["backup_api_base"] == "https://openai.example/v1" diff --git a/tests/configs/test_parser.py b/tests/configs/test_parser.py index 2d4064af3..16c2fddf2 100644 --- a/tests/configs/test_parser.py +++ b/tests/configs/test_parser.py @@ -1,3 +1,5 @@ +import warnings + from memos.configs.parser import BaseParserConfig, MarkItDownParserConfig, ParserConfigFactory from tests.utils import ( check_config_base_class, @@ -52,3 +54,20 @@ def test_parser_config_factory(): ) check_config_instantiation_invalid(ParserConfigFactory) + + +def test_parser_config_factory_dump_without_serializer_warning(): + config = ParserConfigFactory.model_validate( + { + "backend": "markitdown", + "config": {}, + } + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + dumped = config.model_dump() + + assert dumped == {"backend": "markitdown", "config": {}} + assert isinstance(config.config, MarkItDownParserConfig) + assert not any("PydanticSerializationUnexpectedValue" in str(item.message) for item in caught) diff --git a/tests/mem_feedback/test_feedback.py b/tests/mem_feedback/test_feedback.py index 7e8d3b718..bbf30ae94 100644 --- a/tests/mem_feedback/test_feedback.py +++ b/tests/mem_feedback/test_feedback.py @@ -1,6 +1,11 @@ from unittest.mock import Mock from memos.mem_feedback.feedback import MemFeedback +from memos.memories.textual.item import TextualMemoryItem +from memos.templates.mem_feedback_prompts import ( + OPERATION_UPDATE_JUDGEMENT, + OPERATION_UPDATE_JUDGEMENT_ZH, +) def test_process_feedback_runs_answer_and_core_workflows(): @@ -43,3 +48,94 @@ def test_process_feedback_runs_answer_and_core_workflows(): session_id="session-1", task_id="task-1", ) + + +def test_standard_operations_downgrades_large_update_to_add(): + feedback = MemFeedback.__new__(MemFeedback) + memory_id = "8583d7dd-28ba-422c-a9e7-0cd2ec90bc6c" + old_memory = "用户喜欢简洁的技术文档。" + new_memory = "完全不同的新事实,涉及商城订单售后调价风险、退款金额差异和业务确认事项。" + current_memories = [TextualMemoryItem(id=memory_id, memory=old_memory)] + + operations = feedback.standard_operations( + [ + { + "id": memory_id, + "text": new_memory, + "operation": "UPDATE", + "old_memory": old_memory, + } + ], + current_memories, + ) + + assert operations == [ + { + "operation": "ADD", + "text": new_memory, + "_downgraded_from_update": True, + } + ] + + +def test_standard_operations_keeps_downgraded_add_when_other_updates_exist(): + feedback = MemFeedback.__new__(MemFeedback) + downgraded_id = "8583d7dd-28ba-422c-a9e7-0cd2ec90bc6c" + update_id = "35098647-1d67-4a29-aae8-134fefc2f6b0" + current_memories = [ + TextualMemoryItem(id=downgraded_id, memory="用户喜欢简洁的技术文档。"), + TextualMemoryItem(id=update_id, memory="用户在A公司工作。"), + ] + + operations = feedback.standard_operations( + [ + { + "id": downgraded_id, + "text": "完全不同的新事实,涉及商城订单售后调价风险、退款金额差异和业务确认事项。", + "operation": "UPDATE", + "old_memory": "用户喜欢简洁的技术文档。", + }, + { + "id": update_id, + "text": "用户在B公司工作。", + "operation": "UPDATE", + "old_memory": "用户在A公司工作。", + }, + ], + current_memories, + ) + + assert { + "operation": "ADD", + "text": "完全不同的新事实,涉及商城订单售后调价风险、退款金额差异和业务确认事项。", + "_downgraded_from_update": True, + } in operations + assert any( + item.get("operation") == "UPDATE" and item.get("id") == update_id for item in operations + ) + + +def test_standard_operations_skips_add_without_text(): + feedback = MemFeedback.__new__(MemFeedback) + + operations = feedback.standard_operations( + [ + {"operation": "ADD"}, + {"operation": "ADD", "text": ""}, + {"operation": "ADD", "text": "用户喜欢简洁回答。"}, + {"operation": "ADD", "text": "用户喜欢简洁回答。"}, + ], + current_memories=[], + ) + + assert operations == [{"operation": "ADD", "text": "用户喜欢简洁回答。"}] + + +def test_update_judgement_prompt_omits_memory_text_from_response_schema(): + for prompt in [OPERATION_UPDATE_JUDGEMENT, OPERATION_UPDATE_JUDGEMENT_ZH]: + output_section = prompt.split("Output Format")[-1].split("Example 1")[0] + output_section = output_section.split("输出格式")[-1].split("示例1")[0] + + assert '"reason"' in output_section + assert '"text"' not in output_section + assert '"old_memory"' not in output_section diff --git a/tests/mem_reader/test_preference_extractor_llm_config.py b/tests/mem_reader/test_preference_extractor_llm_config.py index b94acda7d..6eaca9114 100644 --- a/tests/mem_reader/test_preference_extractor_llm_config.py +++ b/tests/mem_reader/test_preference_extractor_llm_config.py @@ -16,35 +16,37 @@ def test_product_default_config_wires_preference_extractor_model(monkeypatch): monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "pref-model") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_BASE", "https://pref.example/v1") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_KEY", "pref-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") config = APIConfig.get_product_default_config()["mem_reader"]["config"] pref_config = config["preference_extractor_llm"] assert pref_config["backend"] == "openai" assert pref_config["config"]["model_name_or_path"] == "pref-model" - assert pref_config["config"]["api_base"] == "https://pref.example/v1" - assert pref_config["config"]["api_key"] == "pref-key" + assert pref_config["config"]["api_base"] == "https://openai.example/v1" + assert pref_config["config"]["api_key"] == "openai-key" def test_preference_extractor_qwen35_disables_thinking(monkeypatch): monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "qwen3.5-flash") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_BASE", "https://dashscope.example/v1") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_KEY", "pref-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") pref_config = APIConfig.get_preference_extractor_llm_config() + assert pref_config["backend"] == "qwen" assert pref_config["config"]["extra_body"] == {"enable_thinking": False} def test_preference_extractor_qwen36_disables_thinking(monkeypatch): monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "qwen3.6-flash") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_BASE", "https://dashscope.example/v1") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_KEY", "pref-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") pref_config = APIConfig.get_preference_extractor_llm_config() + assert pref_config["backend"] == "qwen" assert pref_config["config"]["extra_body"] == {"enable_thinking": False} diff --git a/tests/mem_scheduler/test_config.py b/tests/mem_scheduler/test_config.py index 729023490..287245ed1 100644 --- a/tests/mem_scheduler/test_config.py +++ b/tests/mem_scheduler/test_config.py @@ -97,7 +97,8 @@ def test_env_config_mixin_integration(self): """Test EnvConfigMixin integration with actual configuration classes""" # Set complete test environment variables test_env_vars = { - f"{ENV_PREFIX}OPENAI_API_KEY": "test-api-key-12345", + "OPENAI_API_KEY": "test-api-key-12345", + "OPENAI_API_BASE": "https://api.test.openai.com/v1", f"{ENV_PREFIX}OPENAI_DEFAULT_MODEL": "gpt-4", f"{ENV_PREFIX}RABBITMQ_HOST_NAME": "localhost", f"{ENV_PREFIX}RABBITMQ_PORT": "5672", @@ -122,6 +123,7 @@ def test_env_config_mixin_integration(self): # Test various configuration classes openai_config = OpenAIConfig.from_env() self.assertEqual(openai_config.api_key, "test-api-key-12345") + self.assertEqual(openai_config.base_url, "https://api.test.openai.com/v1") self.assertEqual(openai_config.default_model, "gpt-4") rabbitmq_config = RabbitMQConfig.from_env() @@ -145,6 +147,7 @@ class TestSchedulerConfig(unittest.TestCase): def setUp(self): self.env_backup = dict(os.environ) self._clear_prefixed_env_vars() + self._clear_unified_openai_env_vars() def tearDown(self): os.environ.clear() @@ -155,6 +158,10 @@ def _clear_prefixed_env_vars(self): if key.startswith(ENV_PREFIX): del os.environ[key] + def _clear_unified_openai_env_vars(self): + for key in ["OPENAI_API_KEY", "OPENAI_API_BASE"]: + os.environ.pop(key, None) + def test_loads_all_configs_from_env(self): """Test loading all configurations from prefixed environment variables""" os.environ.update( @@ -167,8 +174,8 @@ def test_loads_all_configs_from_env(self): f"{ENV_PREFIX}RABBITMQ_ERASE_ON_CONNECT": "false", f"{ENV_PREFIX}RABBITMQ_PORT": "5673", # OpenAI configs - f"{ENV_PREFIX}OPENAI_API_KEY": "test_api_key", - f"{ENV_PREFIX}OPENAI_BASE_URL": "https://api.test.openai.com", + "OPENAI_API_KEY": "test_api_key", + "OPENAI_API_BASE": "https://api.test.openai.com/v1", f"{ENV_PREFIX}OPENAI_DEFAULT_MODEL": "gpt-test", # GraphDBAuthConfig configs - NOTE THE CORRECT PREFIX! f"{ENV_PREFIX}GRAPHDBAUTH_URI": "bolt://test.db:7687", @@ -195,7 +202,7 @@ def test_uses_default_values_when_env_not_set(self): # RabbitMQ f"{ENV_PREFIX}RABBITMQ_HOST_NAME": "rabbit.test.com", # OpenAI - f"{ENV_PREFIX}OPENAI_API_KEY": "test_api_key", + "OPENAI_API_KEY": "test_api_key", # GraphDB - with correct prefix and valid password length f"{ENV_PREFIX}GRAPHDBAUTH_URI": "bolt://test.db:7687", f"{ENV_PREFIX}GRAPHDBAUTH_PASSWORD": "default_pass", # 11 chars (valid) @@ -242,7 +249,7 @@ def test_type_conversion(self): f"{ENV_PREFIX}RABBITMQ_PORT": "1234", f"{ENV_PREFIX}RABBITMQ_ERASE_ON_CONNECT": "yes", # OpenAI - f"{ENV_PREFIX}OPENAI_API_KEY": "test_api_key", + "OPENAI_API_KEY": "test_api_key", # GraphDB - correct prefix and valid password f"{ENV_PREFIX}GRAPHDBAUTH_URI": "bolt://test.db:7687", f"{ENV_PREFIX}GRAPHDBAUTH_PASSWORD": "type_conv_pass", # 13 chars (valid) @@ -279,7 +286,7 @@ def test_combined_with_local_config(self): os.environ.update( { f"{ENV_PREFIX}RABBITMQ_HOST_NAME": "env.rabbit.com", - f"{ENV_PREFIX}OPENAI_API_KEY": "env_api_key", + "OPENAI_API_KEY": "env_api_key", f"{ENV_PREFIX}GRAPHDBAUTH_USER": "env_user", f"{ENV_PREFIX}GRAPHDBAUTH_PASSWORD": "env_db_pass", # 11 chars (valid) } diff --git a/tests/mem_scheduler/test_openai_env_unification.py b/tests/mem_scheduler/test_openai_env_unification.py new file mode 100644 index 000000000..96711422c --- /dev/null +++ b/tests/mem_scheduler/test_openai_env_unification.py @@ -0,0 +1,32 @@ +from unittest.mock import patch + +from memos.mem_scheduler.analyzer.eval_analyzer import EvalAnalyzer + + +def test_eval_analyzer_uses_unified_openai_endpoint_env(monkeypatch, tmp_path): + monkeypatch.setenv("OPENAI_API_KEY", "unified-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://unified.example/v1") + legacy_prefix = "MEMSCHEDULER_OPENAI_" + monkeypatch.setenv(legacy_prefix + "API_KEY", "legacy-key") + monkeypatch.setenv(legacy_prefix + "BASE_URL", "https://legacy.example/v1") + monkeypatch.delenv("MEMSCHEDULER_OPENAI_DEFAULT_MODEL", raising=False) + + with patch("memos.mem_scheduler.analyzer.eval_analyzer.OpenAI") as openai: + analyzer = EvalAnalyzer(output_dir=str(tmp_path)) + + openai.assert_called_once_with( + api_key="unified-key", + base_url="https://unified.example/v1", + ) + assert analyzer.openai_model == "gpt-4o-mini" + + +def test_eval_analyzer_keeps_scheduler_default_model_override(monkeypatch, tmp_path): + monkeypatch.setenv("OPENAI_API_KEY", "unified-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://unified.example/v1") + monkeypatch.setenv("MEMSCHEDULER_OPENAI_DEFAULT_MODEL", "gpt-4.1-mini") + + with patch("memos.mem_scheduler.analyzer.eval_analyzer.OpenAI"): + analyzer = EvalAnalyzer(output_dir=str(tmp_path)) + + assert analyzer.openai_model == "gpt-4.1-mini" diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index e9c81c7f0..5fb1963a4 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -2,7 +2,6 @@ from memos.hello_world import ( memos_chend_hello_world, - memos_chentang_hello_world, memos_dany_hello_world, memos_hello_world, memos_huojh_hello_world, @@ -115,27 +114,3 @@ def test_memos_yuqingchen_hello_world_logger_called(): assert result == "Hello world from memos-yuqingchen!" mock_logger.assert_called_once_with("memos_yuqingchen_hello_world function called.") - - -def test_memos_chen_tang_hello_world(): - import warnings - - from memos.memories.textual.general import GeneralTextMemory - - # Define return values for os.getenv - def mock_getenv(key, default=None): - mock_values = { - "MODEL": "mock-model-name", - "OPENAI_API_KEY": "mock-api-key", - "OPENAI_BASE_URL": "mock-api-url", - "EMBEDDING_MODEL": "mock-embedding-model", - } - return mock_values.get(key, default) - - # Filter Pydantic serialization warnings - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=UserWarning, module="pydantic") - # Use patch to mock os.getenv - with patch("os.getenv", side_effect=mock_getenv): - memory = memos_chentang_hello_world() - assert isinstance(memory, GeneralTextMemory)