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
+46 -8
View File
@@ -54,8 +54,9 @@ def make_api(**kw):
return api, rec
# --- Rule 1: the project pin travels in the body, never as a query param ---
def test_writes_pin_project_in_body():
# --- Rule 1: a scope OVERRIDE travels in the body, never as a query param.
# Omitting it is normal: the API key is already bound to one (org, project). ---
def test_writes_send_the_override_in_the_body():
api, rec = make_api()
api.add([{"role": "user", "content": "hi"}], user_id="u", infer=True)
assert rec.last["body"]["project_id"] == "proj_Y"
@@ -63,24 +64,61 @@ def test_writes_pin_project_in_body():
assert "project_id=" not in rec.last["url"]
def test_reads_pin_project_in_body():
def test_reads_send_the_override_in_the_body():
api, rec = make_api()
api.get_all(F.all_in_scope("u", "app"))
assert rec.last["body"]["project_id"] == "proj_Y"
assert "project_id=" not in rec.last["url"]
def test_feedback_carries_the_pin_or_it_404s_in_production():
def test_feedback_carries_the_override_when_one_is_configured():
"""Feedback 404s only when the memory lives in a project the key is not bound to and
no override is sent -- not, as first assumed, on every unpinned call."""
api, rec = make_api()
api.feedback("mem-1", "POSITIVE", "referenced in session")
assert rec.last["body"]["project_id"] == "proj_Y"
assert rec.last["body"]["org_id"] == "org_X"
def test_strict_mode_refuses_unpinned_calls():
api = Api("key", strict=True)
with pytest.raises(ContractError):
api._pin()
def test_unpinned_calls_send_no_scope_and_let_the_key_decide():
"""An API key is already bound to one (org, project) server-side, so the ids are an
override, not a requirement. Verified live: add, get_all and feedback all return 200
with nothing in the body."""
api, rec = make_api()
api.org_id = api.project_id = None
api.add([{"role": "user", "content": "hi"}], user_id="u", infer=True)
body = rec.last["body"]
assert "project_id" not in body and "org_id" not in body
api.get_all(F.all_in_scope("u", "app"))
assert "project_id" not in rec.last["body"]
api.feedback("mem-1", "POSITIVE")
assert "project_id" not in rec.last["body"]
def test_override_travels_in_the_body_when_both_ids_are_set():
api, rec = make_api()
api.get_all(F.all_in_scope("u", "app"))
assert rec.last["body"]["project_id"] == "proj_Y"
assert rec.last["body"]["org_id"] == "org_X"
assert "project_id=" not in rec.last["url"], "query params are silently ignored"
def test_half_an_override_is_no_override():
"""One id alone would be ignored by the backend; sending it would only mislead."""
api, _ = make_api()
api.org_id = None
assert api._pin() == {}
def test_project_endpoints_resolve_scope_from_the_key():
"""Only the project-config endpoints need the ids, and they ask the API for them."""
api, rec = make_api(recorder_kw={"body": {"org_id": "org_ping", "project_id": "proj_ping"}})
api.org_id = api.project_id = None
api.project_get(fields=["decay"])
assert "/organizations/org_ping/projects/proj_ping/" in rec.last["url"]
assert rec.calls[0]["url"].endswith("/v1/ping/"), "scope comes from ping, not from config"
# --- Rule 2: latest_only on every read, or superseded facts resurface ---