"""Handler mixin:IdentityMemoryMixin。 由 CloudControlPlaneHandler 拆出,方法体逐字搬移; self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等) 在混入后的同一类上解析,行为不变。本模块不 import cloud.server。 """ from cloud.auto_memory import AutoMemoryStore from cloud.cost import CostStore from cloud.identity import IdentityStore from cloud.kb import KBStore from cloud.memory import MemoryStore from cloud.relay import RelayError as _RelayError from cloud.sub2api import Sub2apiError as _Sub2apiError from cloud.auto_memory import process_due_tasks as _auto_process_due from cloud.sub2api import gateway_name as _gateway_name from cloud.sub2api import gateway_origin as _gateway_origin from cloud.relay import get_relay_models as _get_relay_models from cloud.sub2api import list_gateway_models as _list_sub2api_models from cloud.sub2api import validate_or_rotate_gateway_key as _validate_sub2api_key import os import sqlite3 from cloud.handler_support import ( _auth_store as _auth_store, _task_to_dict as _task_to_dict, ) class IdentityMemoryMixin: """IdentityMemoryMixin 领域方法(见模块 docstring)。""" def _memory_create(self, user_id, project_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return goal = body.get("goal") conclusion = body.get("conclusion") if not goal or not isinstance(goal, str): self._send_json(400, {"error": "goal required"}) return if not conclusion or not isinstance(conclusion, str): self._send_json(400, {"error": "conclusion required"}) return store = MemoryStore(self.server.db_path) try: mid = store.add_summary( project_id, body.get("thread_id") or "", goal, conclusion, files_changed=body.get("files_changed") or [], open_issues=body.get("open_issues") or [], ) finally: try: store.conn.close() except Exception: pass with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "create_memory", str(mid)) self._send_json(201, {"id": mid, "project_id": project_id}) def _memory_list(self, project_id, query): try: top_k = int((query.get("top_k") or ["5"])[0]) except (TypeError, ValueError): self._send_json(400, {"error": "top_k must be an integer"}) return store = MemoryStore(self.server.db_path) try: summaries = store.list_summaries(project_id) context = store.build_prompt_context(project_id, top_k=top_k) finally: try: store.conn.close() except Exception: pass self._send_json(200, { "project_id": project_id, "summaries": summaries[:top_k], "prompt_context": context, }) def _memory_supersede(self, user_id, project_id, mid_raw): try: mid = int(mid_raw) except (TypeError, ValueError): self._send_json(400, {"error": "id must be an integer"}) return conn = self._db() try: row = conn.execute( "SELECT project_id FROM summaries WHERE id = ?", (mid,) ).fetchone() finally: conn.close() if row is None or row["project_id"] != project_id: self._send_json(404, {"error": "summary not found"}) return store = MemoryStore(self.server.db_path) try: store.deprecate(mid) finally: try: store.conn.close() except Exception: pass with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "supersede_memory", str(mid)) self._send_json(200, {"id": mid, "status": "deprecated"}) # -- System-level identity / memory (cloud only) ------------------ # These handlers never touch host.py, CODEX_HOME or any thread: # they only read/write the control-plane database. The context # endpoint returns text for an explicit caller; it does not # inject anything into a task or a thread on its own. def _identity_store(self): return IdentityStore(self.server.db_path) def _identity_close(self, store): try: store.conn.close() except Exception: pass def _identity_profile_get(self, user_id): store = self._identity_store() try: self._send_json(200, store.get_profile(user_id)) finally: self._identity_close(store) def _identity_profile_upsert(self, user_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return store = self._identity_store() try: try: profile = store.upsert_profile( user_id, display_name=body.get("display_name"), timezone=body.get("timezone"), preferences=body.get("preferences")) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: self._identity_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, "", "profile_updated", user_id) self._send_json(200, profile) def _identity_user_memories_list(self, user_id): store = self._identity_store() try: rows = store.list_user_memories(user_id) finally: self._identity_close(store) self._send_json(200, {"user_id": user_id, "memories": rows}) def _identity_user_memory_create(self, user_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return store = self._identity_store() try: try: mem_id = store.add_memory( user_id, "user", body.get("kind"), body.get("content"), source_thread_id=body.get("source_thread_id")) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: self._identity_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, "", "identity_memory_created", mem_id) self._send_json(201, {"id": mem_id, "scope": "user"}) def _identity_user_memory_supersede(self, user_id, mem_id): body, err = self._read_json() if err: body = {} store = self._identity_store() try: row = store.get_memory(mem_id) if row is None or row["scope"] != "user" \ or row["owner_user_id"] != user_id: self._send_json(404, {"error": "memory not found"}) return try: _old, new_id = store.supersede_memory( mem_id, user_id, replacement_content=body.get("replacement_content"), replacement_kind=body.get("replacement_kind")) except (KeyError, PermissionError): self._send_json(404, {"error": "memory not found"}) return except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: self._identity_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, "", "identity_memory_superseded", mem_id) self._send_json(200, {"id": mem_id, "status": "superseded", "replacement_id": new_id}) def _identity_user_memory_delete(self, user_id, mem_id): store = self._identity_store() try: row = store.get_memory(mem_id) if row is None or row["scope"] != "user" \ or row["owner_user_id"] != user_id: self._send_json(404, {"error": "memory not found"}) return try: store.delete_memory(mem_id, user_id) except (KeyError, PermissionError): self._send_json(404, {"error": "memory not found"}) return finally: self._identity_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, "", "identity_memory_deleted", mem_id) self._send_json(200, {"deleted": True, "id": mem_id}) def _identity_context(self, user_id, query): project_id = None if query: vals = query.get("project_id") if vals: project_id = vals[0] if project_id: err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return store = self._identity_store() try: profile = store.get_profile(user_id) user_rows = store.list_user_memories(user_id) proj_rows = store.list_project_memories(project_id) \ if project_id else [] context = store.build_identity_context(user_id, project_id) finally: self._identity_close(store) summaries_context = "" if project_id: mem_store = MemoryStore(self.server.db_path) try: summaries_context = mem_store.build_prompt_context( project_id) finally: try: mem_store.conn.close() except Exception: pass self._send_json(200, { "user_id": user_id, "project_id": project_id, "profile": profile, "user_memories": user_rows, "project_memories": proj_rows, "identity_context": context, # Kept as a separate field on purpose: the existing # project summaries are NOT merged into identity_context # and neither field is injected into any task/thread here. "project_summaries_context": summaries_context, }) # -- 自动记忆(官方 memories 对齐:设置 + 提取触发) ------------ # 设置与提取日志只归属本人;任务级开关走项目 create_task 门。 def _auto_memory_store(self): return AutoMemoryStore(self.server.db_path) def _auto_memory_close(self, store): try: store.close() except Exception: pass def _auto_memory_settings_get(self, user_id): store = self._auto_memory_store() try: self._send_json(200, store.get_settings(user_id)) finally: self._auto_memory_close(store) def _auto_memory_settings_upsert(self, user_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return if not isinstance(body, dict): self._send_json(400, {"error": "body must be an object"}) return store = self._auto_memory_store() try: try: settings = store.upsert_settings(user_id, **body) except (ValueError, TypeError) as exc: self._send_json(400, {"error": str(exc)}) return finally: self._auto_memory_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, "", "auto_memory_settings_updated", "") self._send_json(200, settings) def _auto_memory_log(self, user_id, query): try: limit = int((query.get("limit") or ["50"])[0]) except (TypeError, ValueError): self._send_json(400, {"error": "limit must be an integer"}) return store = self._auto_memory_store() try: rows = store.list_log(user_id, limit=limit) finally: self._auto_memory_close(store) self._send_json(200, {"user_id": user_id, "log": rows}) def _auto_memory_run(self, user_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return force = False if isinstance(body, dict) and body.get("force") is not None: if body.get("force") not in (True, False, 0, 1): self._send_json(400, {"error": "force must be a boolean"}) return force = bool(body.get("force")) results = _auto_process_due( self.server.db_path, force=force, owner_id=user_id) for item in results: if item.get("status") == "extracted": # 提取日志已在 process_due_tasks 里落库,这里只补 # 一条审计(不查任务归属:run 只扫本人的任务)。 task_id = item.get("task_id") or "" with _auth_store(self.server.db_path) as auth: auth.audit(user_id, "", "auto_memory_extracted", task_id) self._send_json(200, {"user_id": user_id, "results": results}) def _task_auto_memory_flags(self, user_id, task_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return if not isinstance(body, dict): self._send_json(400, {"error": "body must be an object"}) return task = self._fetch_task(task_id) if task is None: self._send_json(404, {"error": "task not found"}) return err = self._check_project( user_id, task["project_id"], "create_task") if err: self._send_json(err[0], {"error": err[1]}) return store = self._auto_memory_store() try: try: flags = store.set_task_flags( task_id, use_memories=body.get("use_memories"), generate_memories=body.get("generate_memories")) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: self._auto_memory_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, task["project_id"], "task_auto_memory_flags", task_id) self._send_json(200, {"task_id": task_id, "flags": flags}) def _project_identity_memory_create(self, user_id, project_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return store = self._identity_store() try: try: mem_id = store.add_memory( user_id, "project", body.get("kind"), body.get("content"), project_id=project_id, source_thread_id=body.get("source_thread_id")) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: self._identity_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "identity_memory_created", mem_id) self._send_json(201, {"id": mem_id, "scope": "project", "project_id": project_id}) def _project_identity_memories_list(self, project_id, query): try: top_k = int((query.get("top_k") or ["20"])[0]) except (TypeError, ValueError): self._send_json(400, {"error": "top_k must be an integer"}) return store = self._identity_store() try: rows = store.list_project_memories(project_id) finally: self._identity_close(store) self._send_json(200, {"project_id": project_id, "memories": rows[:top_k]}) def _project_identity_memory_supersede(self, user_id, project_id, mem_id): body, err = self._read_json() if err: body = {} store = self._identity_store() try: row = store.get_memory(mem_id) if row is None or row["scope"] != "project" \ or row["project_id"] != project_id: self._send_json(404, {"error": "memory not found"}) return try: _old, new_id = store.supersede_memory( mem_id, user_id, replacement_content=body.get("replacement_content"), replacement_kind=body.get("replacement_kind")) except (KeyError, PermissionError, ValueError) as exc: if isinstance(exc, ValueError): self._send_json(400, {"error": str(exc)}) else: self._send_json(404, {"error": "memory not found"}) return finally: self._identity_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "identity_memory_superseded", mem_id) self._send_json(200, {"id": mem_id, "status": "superseded", "replacement_id": new_id}) def _project_identity_memory_delete(self, user_id, project_id, mem_id): store = self._identity_store() try: row = store.get_memory(mem_id) if row is None or row["scope"] != "project" \ or row["project_id"] != project_id: self._send_json(404, {"error": "memory not found"}) return store.delete_memory(mem_id, user_id) finally: self._identity_close(store) with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "identity_memory_deleted", mem_id) self._send_json(200, {"deleted": True, "id": mem_id}) def _kb_index(self, user_id, project_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return path = body.get("path") content = body.get("content") if not path or not isinstance(path, str): self._send_json(400, {"error": "path required"}) return if content is None or not isinstance(content, str): self._send_json(400, {"error": "content required"}) return store = KBStore(self.server.db_path) try: doc_id = store.index_file( project_id, path, content, kind=body.get("kind") or "file") except ValueError as e: self._send_json(400, {"error": str(e)}) return finally: try: store.conn.close() except Exception: pass with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "kb_index", path) self._send_json(201, {"id": doc_id, "project_id": project_id, "path": path}) def _kb_search(self, project_id, query): q = (query.get("q") or [""])[0] if not q: self._send_json(400, {"error": "q required"}) return try: top_k = int((query.get("top_k") or ["5"])[0]) except (TypeError, ValueError): self._send_json(400, {"error": "top_k must be an integer"}) return store = KBStore(self.server.db_path) try: results = store.search(project_id, q, top_k=top_k) finally: try: store.conn.close() except Exception: pass self._send_json(200, {"project_id": project_id, "query": q, "results": results}) def _project_cost(self, project_id, query): store = CostStore(self.server.db_path) try: # 双列:美元只计有价行,未定价模型单独列在 # unpriced_models,billing_note 标注代理价、非账单。 overall = store.dual_column_report_by_project(project_id) conn = store._conn conn.row_factory = sqlite3.Row days = (query.get("days") or [None])[0] day = (query.get("day") or [None])[0] daily = [] if day: row = conn.execute( "SELECT day, COALESCE(SUM(total_tokens),0) AS t," " COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c," " COALESCE(SUM(fallback_count),0) AS f," " COALESCE(SUM(cached_input_tokens),0) AS cached," " COALESCE(SUM(CASE WHEN source = 'actual'" " AND usd_micros IS NULL THEN total_tokens" " ELSE 0 END),0) AS unpriced" " FROM cost_records WHERE project_id = ? AND day = ?" " GROUP BY day", (project_id, day)).fetchone() if row: daily = [{"day": row["day"], "total_tokens": int(row["t"]), "usd_micros": int(row["u"]), "turns": int(row["c"]), "fallback_count": int(row["f"]), "cached_input_tokens": int(row["cached"]), "unpriced_actual_tokens": int(row["unpriced"])}] elif days is not None: try: n = int(days) except (TypeError, ValueError): self._send_json(400, {"error": "days must be an integer"}) return from datetime import date, timedelta cutoff = (date.today() - timedelta(days=max(n - 1, 0)) ).isoformat() rows = conn.execute( "SELECT day, COALESCE(SUM(total_tokens),0) AS t," " COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c," " COALESCE(SUM(fallback_count),0) AS f," " COALESCE(SUM(cached_input_tokens),0) AS cached," " COALESCE(SUM(CASE WHEN source = 'actual'" " AND usd_micros IS NULL THEN total_tokens" " ELSE 0 END),0) AS unpriced" " FROM cost_records WHERE project_id = ? AND day >= ?" " GROUP BY day ORDER BY day", (project_id, cutoff)).fetchall() daily = [{"day": r["day"], "total_tokens": int(r["t"]), "usd_micros": int(r["u"]), "turns": int(r["c"]), "fallback_count": int(r["f"]), "cached_input_tokens": int(r["cached"]), "unpriced_actual_tokens": int(r["unpriced"])} for r in rows] finally: try: store._conn.close() except Exception: pass self._send_json(200, {"project_id": project_id, "overall": overall, "daily": daily}) # -- 任务成本与模型总览 ---------------------------------------------- def _task_cost(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as auth: if not auth.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return store = CostStore(self.server.db_path) try: # 双列:与项目成本同款,旧键全保留,只加双列键。 report = store.dual_column_report_by_task(task_id) finally: try: store._conn.close() except Exception: pass self._send_json(200, {"task_id": task_id, "project_id": task["project_id"], **report}) def _relay_models(self, user_id): # 中转可用模型列表:服务端凭环境变量里的 relay key 现取 # 现回,只把模型 id 交给调用者。key 绝不进响应、日志、 # 缓存(见 cloud/relay.py)。单测可经 server.relay_fetcher # 注入假取数函数;中转挂了或未配 key 时 502,不编造列表。 # 网关为 sub2api 时:模型目录因用户而异(key 绑定的组 # 决定)。P1 收紧:首次调模型目录即做真正的懒开通/ # 健康检查,不再只回 provisioning: pending——无行则开通, # 旧 key 经 /v1/models 实测已死(401/403,含余额为 0 的 # 403)则轮换重开;网关本身不可用时回 502、保留旧行。 # key 明文绝不进响应、日志、缓存。 if _gateway_name() == "sub2api": conn = self._db() try: try: status, api_key, _sub, _kid = _validate_sub2api_key( conn, user_id) except _Sub2apiError as exc: self._send_json(502, {"error": str(exc)}) return except Exception as exc: self._send_json(502, {"error": str(exc)}) return finally: conn.close() try: models = _list_sub2api_models(_gateway_origin(), api_key) except Exception as exc: self._send_json(502, {"error": str(exc)}) return self._send_json(200, {"models": models, "gateway": "sub2api", "cached": False, "updated_at": None, "provisioning": status}) return fetcher = getattr(self.server, "relay_fetcher", None) try: models, cached, updated_at = _get_relay_models(fetcher=fetcher) except _RelayError as exc: self._send_json(502, {"error": str(exc)}) return self._send_json(200, { "models": models, "cached": cached, "updated_at": updated_at, "gateway": "xcmapi"}) def _models_overview(self, user_id): # 优先级列表来自环境变量 MODELS_PRIORITY(逗号分隔),未设时 # 用默认列表;这只是控制面的默认配置,不是 provider 的承诺。 raw = os.environ.get("MODELS_PRIORITY", "") priority = [m.strip() for m in raw.split(",") if m.strip()] or [ "gpt-6.1-sol", "gpt-6-sol", "gpt-5.6-sol"] # 只统计调用者是成员的项目,避免跨项目泄露成本。 conn = self._db() try: member_ids = [r["project_id"] for r in conn.execute( "SELECT project_id FROM project_members WHERE user_id = ?", (user_id,)).fetchall()] finally: conn.close() if not member_ids: self._send_json(200, {"priority_list": priority, "model_stats": [], "fallback_events": []}) return placeholders = ",".join("?" for _ in member_ids) store = CostStore(self.server.db_path) try: conn = store._conn conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT model, COUNT(*) AS turns," " COALESCE(SUM(total_tokens),0) AS tokens," " COALESCE(SUM(fallback_count),0) AS fallbacks" " FROM cost_records WHERE project_id IN (%s)" " GROUP BY model ORDER BY model" % placeholders, member_ids).fetchall() fb_rows = conn.execute( "SELECT task_id, project_id, model, fallback_count" " FROM cost_records WHERE project_id IN (%s)" " AND fallback_count > 0" " ORDER BY created_at DESC LIMIT 50" % placeholders, member_ids).fetchall() finally: try: store._conn.close() except Exception: pass self._send_json(200, { "priority_list": priority, "model_stats": [ {"model": r["model"], "turns": int(r["turns"]), "total_tokens": int(r["tokens"]), "fallback_count": int(r["fallbacks"])} for r in rows], "fallback_events": [ {"task_id": r["task_id"], "project_id": r["project_id"], "model": r["model"], "fallback_count": int(r["fallback_count"])} for r in fb_rows], })