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:
co-authored by
Claude Fable 5
parent
dda5f21332
commit
f4949c55af
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user