refactor(mem0-agent): stop requiring org/project in client config

An API key is already bound to one (org_id, project_id) server-side, so storing a
copy locally was redundant state that can go stale. Verified live that add,
get_all and feedback all succeed with no ids in the body at all.

- both ids are now purely an OVERRIDE, for pointing one key at a different
  project in the same org; half an override is ignored rather than half-applied
- identity resolution no longer persists scope as a side effect
- the project-config endpoints (the only ones needing ids in the URL) resolve
  them from /v1/ping/ and cache the result
- corrects an over-generalized finding: feedback does not require the ids; it
  404'd earlier only because that memory lived outside the key's project
- onboarding now recommends a key issued for a dedicated project over an override

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Deshraj Yadav
2026-07-29 17:13:52 -07:00
co-authored by Claude Fable 5
parent dda5f21332
commit f4949c55af
6 changed files with 120 additions and 45 deletions
+39 -8
View File
@@ -45,17 +45,39 @@ class Api:
self.strict = strict
self._opener = opener or urllib.request.urlopen
self.last_error: str | None = None
self._scope: tuple[str | None, str | None] | None = None
# ---------- plumbing ----------
def _pin(self, project_id: str | None = None) -> dict[str, str]:
"""Body-level scope OVERRIDE, deliberately empty by default.
An API key is already bound to one (org, project) server-side, so sending nothing
is the normal case and the backend resolves it. These ids are only for pointing a
key at a different project in the same org, and they must travel in the BODY --
as query params they are silently ignored.
"""
pid = project_id or self.project_id
oid = self.org_id
if not pid or not oid:
if self.strict:
raise ContractError("project_id and org_id must be set; unpinned calls leak into the key's default project")
return {}
return {"project_id": pid, "org_id": oid}
def resolve_scope(self) -> tuple[str | None, str | None]:
"""The (org_id, project_id) this key acts on, asked of the API and cached.
Needed only by the project-configuration endpoints, which carry both ids in the
URL path. Memory operations never need this.
"""
if self._scope is None:
status, body = self.ping()
if status == 200 and isinstance(body, dict):
self._scope = (body.get("org_id"), body.get("project_id"))
else:
self._scope = (None, None)
org = self.org_id or self._scope[0]
project = self.project_id or self._scope[1]
return org, project
def call(self, method: str, path: str, body: dict | None = None,
params: dict | None = None, timeout: float = READ_TIMEOUT) -> tuple[int, Any]:
"""Returns (status, parsed_body). Never raises on HTTP or network error."""
@@ -146,18 +168,27 @@ class Api:
# ---------- project config ----------
def project_get(self, *, project_id: str | None = None,
fields: list[str] | None = None) -> tuple[int, Any]:
pid = project_id or self.project_id
params = {"fields": fields} if fields else None # rule 4: repeated params
return self.call("GET", f"/api/v1/orgs/organizations/{self.org_id}/projects/{pid}/",
org, pid = self.resolve_scope()
pid = project_id or pid
if not org or not pid:
return 0, {"error": "could not resolve org/project from the API key"}
params = {"fields": fields} if fields else None # repeated params, not comma-joined
return self.call("GET", f"/api/v1/orgs/organizations/{org}/projects/{pid}/",
None, params=params)
def project_update(self, *, project_id: str | None = None, **kw) -> tuple[int, Any]:
pid = project_id or self.project_id
return self.call("PATCH", f"/api/v1/orgs/organizations/{self.org_id}/projects/{pid}/",
org, pid = self.resolve_scope()
pid = project_id or pid
if not org or not pid:
return 0, {"error": "could not resolve org/project from the API key"}
return self.call("PATCH", f"/api/v1/orgs/organizations/{org}/projects/{pid}/",
kw, timeout=WRITE_TIMEOUT)
def project_create(self, name: str, description: str = "") -> tuple[int, Any]:
return self.call("POST", f"/api/v1/orgs/organizations/{self.org_id}/projects/",
org, _ = self.resolve_scope()
if not org:
return 0, {"error": "could not resolve org from the API key"}
return self.call("POST", f"/api/v1/orgs/organizations/{org}/projects/",
{"name": name, "description": description}, timeout=WRITE_TIMEOUT)
+12 -14
View File
@@ -62,24 +62,22 @@ def build(session_id: str | None = None, cwd: str | None = None, *, strict: bool
if not key:
return Ctx(None, settings, state, "", "", session_id, None, False, "no API key")
org = settings.get("org_id") or os.environ.get("MEM0_ORG_ID")
project = settings.get("memory_project_id") or settings.get("default_project_id") \
or os.environ.get("MEM0_PLATFORM_PROJECT_ID")
# An API key is already bound to one (org, project) on the backend, so neither id is
# required here. They are sent only to OVERRIDE that binding -- i.e. to point one key at
# a different project in the same org. Left unset, every call resolves server-side.
org = settings.get("org_id") or os.environ.get("MEM0_AGENT_ORG_ID")
project = settings.get("memory_project_id") or os.environ.get("MEM0_AGENT_PROJECT_ID")
api = Api(key, org_id=org, project_id=project,
breaker=Breaker(state.breaker_path), strict=strict)
# First run: learn identity and the key's home project from the API itself.
if not org or not project:
status, body = api.ping()
if status == 200 and isinstance(body, dict):
api.org_id = org = body.get("org_id")
api.project_id = project = settings.get("memory_project_id") or body.get("project_id")
settings.set("org_id", org)
settings.set("default_project_id", body.get("project_id"))
else:
return Ctx(None, settings, state, "", "", session_id, None, False, "identity unavailable")
# An override needs both halves; one alone would be ignored and quietly mislead.
if bool(org) != bool(project):
api.org_id = api.project_id = None
settings_warning = "project override ignored: set both org_id and memory_project_id"
else:
settings_warning = ""
user_id = resolve_user_id(api, settings)
app_id = resolve_app_id(cwd, settings)
branch = resolve_branch(cwd)
return Ctx(api, settings, state, user_id, app_id, session_id, branch, True)
return Ctx(api, settings, state, user_id, app_id, session_id, branch, True, settings_warning)
@@ -191,9 +191,11 @@ def run_onboard(interactive: bool = True, **overrides: Any) -> dict:
detail = f"{size} memories" if size is not None else "size unknown"
if busy or size is None:
report["warnings"].append(
f"Using the API key's default project {name or ctx.api.project_id} ({detail}). "
"If it also serves a production app, create a dedicated coding-memory project and "
"set `memory_project_id` in " + str(settings.path) + "."
f"Using the API key's project {name or 'unknown'} ({detail}). "
"If it also serves a production app, the cleanest fix is a separate API key "
"issued for a dedicated coding-memory project -- the key carries the scope, so "
"nothing needs configuring here. As an escape hatch you can override with "
"org_id + memory_project_id in " + str(settings.path) + "."
)
# 4. The memory-mode decision. Default follows what the repo already does.
@@ -134,11 +134,9 @@ def resolve_user_id(api, settings: Settings | None = None) -> str:
email = body.get("user_email") or ""
uid = email.split("@")[0] if email else ""
if uid:
# Cache the identity only. Scope is deliberately NOT persisted: the API key
# already carries it, and storing a copy here would be state that can go stale.
settings.set("user_id", uid)
if body.get("org_id"):
settings.set("org_id", body["org_id"])
if body.get("project_id"):
settings.set("default_project_id", body["project_id"])
return uid
return os.environ.get("USER", "default")