ltcai 10.2.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -44
- package/docs/CHANGELOG.md +125 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/MYPY_BACKLOG.md +65 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +33 -6
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/context.py +23 -3
- package/lattice_brain/graph/_kg_common.py +164 -9
- package/lattice_brain/graph/_kg_contract.py +223 -0
- package/lattice_brain/graph/_kg_fsutil.py +2 -2
- package/lattice_brain/graph/curator.py +4 -4
- package/lattice_brain/graph/discovery.py +12 -1
- package/lattice_brain/graph/discovery_index.py +12 -1
- package/lattice_brain/graph/documents.py +17 -2
- package/lattice_brain/graph/ingest.py +12 -1
- package/lattice_brain/graph/proactive.py +9 -5
- package/lattice_brain/graph/projection.py +12 -1
- package/lattice_brain/graph/provenance.py +12 -1
- package/lattice_brain/graph/retrieval.py +12 -1
- package/lattice_brain/graph/retrieval_docgen.py +12 -1
- package/lattice_brain/graph/retrieval_reads.py +12 -1
- package/lattice_brain/graph/retrieval_vector.py +12 -1
- package/lattice_brain/graph/schema.py +6 -2
- package/lattice_brain/graph/write_master.py +12 -1
- package/lattice_brain/ingestion.py +1 -1
- package/lattice_brain/quality.py +5 -5
- package/lattice_brain/runtime/agent_runtime.py +7 -2
- package/lattice_brain/runtime/contracts.py +2 -1
- package/lattice_brain/runtime/hooks.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/workflow.py +10 -7
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +2 -2
- package/latticeai/api/chat.py +19 -15
- package/latticeai/api/chat_agent_http.py +19 -7
- package/latticeai/api/chat_helpers.py +9 -8
- package/latticeai/api/chat_stream.py +2 -2
- package/latticeai/api/computer_use.py +2 -2
- package/latticeai/api/hooks.py +4 -2
- package/latticeai/api/local_files.py +8 -4
- package/latticeai/api/mcp.py +2 -2
- package/latticeai/api/models.py +5 -5
- package/latticeai/api/permissions.py +2 -2
- package/latticeai/api/search.py +5 -3
- package/latticeai/api/security_dashboard.py +3 -5
- package/latticeai/api/setup.py +3 -3
- package/latticeai/api/static_routes.py +7 -2
- package/latticeai/api/tools.py +9 -4
- package/latticeai/api/workspace.py +25 -22
- package/latticeai/app_factory.py +132 -1346
- package/latticeai/cli/entrypoint.py +1 -1
- package/latticeai/core/agent.py +28 -14
- package/latticeai/core/audit.py +5 -3
- package/latticeai/core/embedding_providers.py +19 -6
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +2 -2
- package/latticeai/core/mcp_catalog.py +3 -1
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/plugins.py +1 -1
- package/latticeai/core/project_sessions.py +1 -1
- package/latticeai/core/run_explain.py +6 -4
- package/latticeai/core/workspace_os.py +0 -4
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_state.py +1 -1
- package/latticeai/core/workspace_snapshots.py +3 -3
- package/latticeai/integrations/telegram_bot.py +52 -3
- package/latticeai/models/router.py +61 -34
- package/latticeai/runtime/access_runtime.py +3 -1
- package/latticeai/runtime/build_phases.py +1417 -0
- package/latticeai/runtime/history_writer.py +138 -0
- package/latticeai/runtime/review_wiring.py +6 -1
- package/latticeai/runtime/runtime_context.py +276 -0
- package/latticeai/runtime/sso_config_runtime.py +1 -1
- package/latticeai/runtime/stages.py +8 -2
- package/latticeai/services/app_context.py +23 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +4 -2
- package/latticeai/services/cloud_streaming.py +5 -3
- package/latticeai/services/command_center.py +1 -1
- package/latticeai/services/evidence_actions.py +2 -1
- package/latticeai/services/hybrid_chat.py +1 -1
- package/latticeai/services/local_knowledge.py +25 -9
- package/latticeai/services/memory_service.py +8 -4
- package/latticeai/services/model_catalog.py +12 -12
- package/latticeai/services/model_engines.py +15 -17
- package/latticeai/services/model_loading.py +1 -1
- package/latticeai/services/model_runtime.py +31 -33
- package/latticeai/services/multimodal_streaming.py +10 -4
- package/latticeai/services/openai_compatible_adapter.py +4 -3
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/run_executor.py +12 -3
- package/latticeai/services/search_service.py +1 -1
- package/latticeai/services/triggers.py +1 -1
- package/latticeai/setup/auto_setup.py +1 -1
- package/latticeai/setup/wizard.py +12 -3
- package/latticeai/tools/computer.py +3 -1
- package/latticeai/tools/documents.py +2 -2
- package/package.json +4 -2
- package/scripts/check_current_release_docs.mjs +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-CbdGD-2i.js → Act-BK__Y5VE.js} +1 -1
- package/static/app/assets/{AdminConsole-LgCkXpnf.js → AdminConsole-ZrTyN8_F.js} +1 -1
- package/static/app/assets/{Brain-CoPGJI1L.js → Brain-CYHDXpPB.js} +1 -1
- package/static/app/assets/{BrainHome-gVnaxSwp.js → BrainHome-bbJulb5g.js} +1 -1
- package/static/app/assets/{BrainSignals-ChxAYHtj.js → BrainSignals-CesSPtUP.js} +1 -1
- package/static/app/assets/{Capture-DBtgkHZg.js → Capture-p5upXmz2.js} +1 -1
- package/static/app/assets/{CommandPalette-Ovtv5I0Y.js → CommandPalette-0zkyC0RY.js} +1 -1
- package/static/app/assets/Library-Dt-vABI2.js +1 -0
- package/static/app/assets/{LivingBrain-CNz-6NSd.js → LivingBrain-Bz-h6LBH.js} +1 -1
- package/static/app/assets/{ProductFlow-ePX-Y73J.js → ProductFlow-DAZ7DJCE.js} +1 -1
- package/static/app/assets/{ReviewCard-C4mpvkwH.js → ReviewCard-Bv0WGbY6.js} +1 -1
- package/static/app/assets/{System-BvWNK1zc.js → System-BhrJQSD_.js} +1 -1
- package/static/app/assets/{activity-CiauPV_3.js → activity-AolpZJCH.js} +1 -1
- package/static/app/assets/{bot-Dwct-Q1x.js → bot-CsVsT2nd.js} +1 -1
- package/static/app/assets/{brain-BKDW1F0T.js → brain-vuppco5y.js} +1 -1
- package/static/app/assets/{button-CCB9Kuw7.js → button-1BP1PJZ3.js} +1 -1
- package/static/app/assets/{circle-pause-DNa8WHC5.js → circle-pause-5paeCOtq.js} +1 -1
- package/static/app/assets/{circle-play-8jmxn5mf.js → circle-play-By8mFYRV.js} +1 -1
- package/static/app/assets/{cpu-mxvF3V1I.js → cpu-DvUQxdKe.js} +1 -1
- package/static/app/assets/{download--SmCcx0p.js → download-CFqYG90a.js} +1 -1
- package/static/app/assets/{folder-open-D4Wy5roo.js → folder-open-CVSnp-Dp.js} +1 -1
- package/static/app/assets/{hard-drive-BwQcSPlS.js → hard-drive-Ckr4Vd6-.js} +1 -1
- package/static/app/assets/{index-DDV2YZwM.js → index--0zKF5Ow.js} +3 -3
- package/static/app/assets/index-CEkL4_M-.css +2 -0
- package/static/app/assets/{input-DFhSjmLS.js → input-D-JKquXU.js} +1 -1
- package/static/app/assets/{network-Bts7VO94.js → network-DB88FZ2T.js} +1 -1
- package/static/app/assets/{primitives-BuSMEJY8.js → primitives-D8U9OaIo.js} +1 -1
- package/static/app/assets/search-B8jsVTw_.js +1 -0
- package/static/app/assets/{shield-alert-BfTO6X_G.js → shield-alert-DnsvO79P.js} +1 -1
- package/static/app/assets/{textarea-BjctW1oh.js → textarea-4RchwbTa.js} +1 -1
- package/static/app/assets/{useFocusTrap-CE43-LrA.js → useFocusTrap-CFJjy7cS.js} +1 -1
- package/static/app/assets/{useQuery-Bv3ejLL5.js → useQuery-3Reu-A4a.js} +1 -1
- package/static/app/assets/{users-CsNqLZAj.js → users-BTxYksxa.js} +1 -1
- package/static/app/assets/{utils-KFFdVG_t.js → utils-D02Vf-fw.js} +1 -1
- package/static/app/assets/{workspace-wdCvdyPF.js → workspace-CSJNgX-C.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Library-CBia2Fvm.js +0 -1
- package/static/app/assets/index-CQWdDU3z.css +0 -2
- package/static/app/assets/search-DZzxhWaQ.js +0 -1
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
os.environ.setdefault("MLX_VLM_DRAFT_KIND", "mtp")
|
|
20
20
|
|
|
21
21
|
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
-
from typing import AsyncIterator, Dict, List, Optional, Tuple
|
|
22
|
+
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
|
|
23
23
|
|
|
24
24
|
from PIL import Image
|
|
25
25
|
|
|
@@ -34,37 +34,44 @@ from latticeai.models.model_providers import ( # noqa: F401
|
|
|
34
34
|
PROVIDER_MODEL_CATALOG,
|
|
35
35
|
)
|
|
36
36
|
|
|
37
|
+
# Optional dependencies. Each is aliased on import and then re-exported as
|
|
38
|
+
# `Any`, so "installed" and "absent" are the same declared type and every
|
|
39
|
+
# call site keeps its historical name.
|
|
37
40
|
try:
|
|
38
|
-
from openai import AsyncOpenAI
|
|
41
|
+
from openai import AsyncOpenAI as _AsyncOpenAI
|
|
39
42
|
except Exception:
|
|
40
|
-
|
|
43
|
+
_AsyncOpenAI = None # type: ignore[assignment,misc]
|
|
44
|
+
AsyncOpenAI: Any = _AsyncOpenAI
|
|
41
45
|
|
|
42
46
|
# 추론 전용 싱글 스레드 워커 (GPU 스트림 보호용)
|
|
43
47
|
executor = ThreadPoolExecutor(max_workers=1)
|
|
44
48
|
|
|
45
49
|
try:
|
|
46
|
-
import mlx.core as
|
|
50
|
+
import mlx.core as _mx
|
|
47
51
|
except Exception as e:
|
|
48
|
-
|
|
52
|
+
_mx = None # type: ignore[assignment]
|
|
49
53
|
print(f"⚠️ MLX core unavailable: {e}")
|
|
54
|
+
mx: Any = _mx
|
|
50
55
|
|
|
51
56
|
try:
|
|
52
|
-
from mlx_vlm import load as
|
|
57
|
+
from mlx_vlm import load as _vlm_load
|
|
53
58
|
VLM_AVAILABLE = True
|
|
54
59
|
print("✅ MLX-VLM is ready for multimodal models.")
|
|
55
60
|
except Exception as e:
|
|
56
|
-
|
|
61
|
+
_vlm_load = None # type: ignore[assignment]
|
|
57
62
|
VLM_AVAILABLE = False
|
|
58
63
|
print(f"⚠️ MLX-VLM unavailable: {e}")
|
|
64
|
+
vlm_load: Any = _vlm_load
|
|
59
65
|
|
|
60
66
|
try:
|
|
61
|
-
from mlx_lm import load as
|
|
67
|
+
from mlx_lm import load as _lm_load
|
|
62
68
|
LM_AVAILABLE = True
|
|
63
69
|
print("✅ MLX-LM is ready for text fallback models.")
|
|
64
70
|
except Exception as e:
|
|
65
|
-
|
|
71
|
+
_lm_load = None # type: ignore[assignment]
|
|
66
72
|
LM_AVAILABLE = False
|
|
67
73
|
print(f"⚠️ MLX-LM unavailable: {e}")
|
|
74
|
+
lm_load: Any = _lm_load
|
|
68
75
|
|
|
69
76
|
BRAND_NAME = "Lattice AI"
|
|
70
77
|
LEGACY_BRAND_PATTERNS = [
|
|
@@ -111,7 +118,11 @@ def normalize_branding(text: Optional[str]) -> str:
|
|
|
111
118
|
return normalized
|
|
112
119
|
|
|
113
120
|
|
|
114
|
-
|
|
121
|
+
# Returns a display payload whose `source_display_order` value is a list,
|
|
122
|
+
# so the value type is Any rather than str.
|
|
123
|
+
def source_metadata_for_model(
|
|
124
|
+
provider: str, model: Dict[str, Any], *, local_server: bool
|
|
125
|
+
) -> Dict[str, Any]:
|
|
115
126
|
family = str(model.get("family") or "")
|
|
116
127
|
country, company = MODEL_SOURCE_BY_FAMILY.get(family, ("미상", provider.title()))
|
|
117
128
|
if local_server:
|
|
@@ -139,7 +150,7 @@ def source_metadata_for_model(provider: str, model: Dict[str, str], *, local_ser
|
|
|
139
150
|
class CloudModel:
|
|
140
151
|
provider: str
|
|
141
152
|
model: str
|
|
142
|
-
client:
|
|
153
|
+
client: Any # AsyncOpenAI when the optional dependency is installed
|
|
143
154
|
cache_key: str
|
|
144
155
|
|
|
145
156
|
def parse_model_ref(model_id: str) -> tuple[str, str]:
|
|
@@ -272,7 +283,9 @@ def _mlx_sampler(temperature: float):
|
|
|
272
283
|
|
|
273
284
|
class LLMRouter:
|
|
274
285
|
def __init__(self):
|
|
275
|
-
|
|
286
|
+
# A local entry is (model, tokenizer, draft_model, loader_kind); a
|
|
287
|
+
# cloud entry is a CloudModel. `_unpack_local_cache` splits them.
|
|
288
|
+
self._cache: Dict[str, Any] = {}
|
|
276
289
|
self._current: Optional[str] = None
|
|
277
290
|
self._last_used: Dict[str, float] = {}
|
|
278
291
|
self._max_local_models = max(1, int(os.getenv("LATTICEAI_MAX_LOCAL_MODELS", "1")))
|
|
@@ -366,8 +379,8 @@ class LLMRouter:
|
|
|
366
379
|
async def load_model(
|
|
367
380
|
self,
|
|
368
381
|
model_id: str,
|
|
369
|
-
adapter_path: str = None,
|
|
370
|
-
draft_model_id: str = None,
|
|
382
|
+
adapter_path: Optional[str] = None,
|
|
383
|
+
draft_model_id: Optional[str] = None,
|
|
371
384
|
api_key_override: Optional[str] = None,
|
|
372
385
|
owner: Optional[str] = None,
|
|
373
386
|
) -> str:
|
|
@@ -449,21 +462,27 @@ class LLMRouter:
|
|
|
449
462
|
|
|
450
463
|
base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
|
|
451
464
|
base_url = base_url or config.get("base_url")
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
465
|
+
# base_url is passed only when configured: an explicit None is not
|
|
466
|
+
# the same as omitting the argument.
|
|
467
|
+
client = (
|
|
468
|
+
AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
469
|
+
if base_url
|
|
470
|
+
else AsyncOpenAI(api_key=api_key)
|
|
471
|
+
)
|
|
455
472
|
|
|
456
473
|
cache_owner = owner or "global"
|
|
457
474
|
cache_key = f"{provider}:{model}::{cache_owner}"
|
|
458
475
|
with self._lock:
|
|
459
|
-
self._cache[cache_key] = CloudModel(
|
|
476
|
+
self._cache[cache_key] = CloudModel(
|
|
477
|
+
provider=provider, model=model, client=client, cache_key=cache_key
|
|
478
|
+
)
|
|
460
479
|
self._current = cache_key
|
|
461
480
|
self._touch(cache_key)
|
|
462
481
|
return f"Cloud provider ready: {cache_key}"
|
|
463
482
|
|
|
464
483
|
def detected_cloud_models(self) -> List[Dict[str, str]]:
|
|
465
484
|
local_server_providers = {"ollama", "vllm", "lmstudio", "llamacpp"}
|
|
466
|
-
items = []
|
|
485
|
+
items: List[Dict[str, Any]] = []
|
|
467
486
|
for provider, config in OPENAI_COMPATIBLE_PROVIDERS.items():
|
|
468
487
|
has_key = bool(os.getenv(config["env_key"]) or config.get("api_key_fallback"))
|
|
469
488
|
provider_models = PROVIDER_MODEL_CATALOG.get(provider) or [{
|
|
@@ -486,19 +505,23 @@ class LLMRouter:
|
|
|
486
505
|
})
|
|
487
506
|
custom = os.getenv("LATTICEAI_CLOUD_MODELS") or ""
|
|
488
507
|
for raw in [item.strip() for item in custom.split(",") if item.strip()]:
|
|
489
|
-
provider,
|
|
508
|
+
provider, custom_model = parse_model_ref(raw)
|
|
490
509
|
if provider != "local_mlx" and provider in OPENAI_COMPATIBLE_PROVIDERS:
|
|
491
510
|
config = OPENAI_COMPATIBLE_PROVIDERS[provider]
|
|
492
511
|
items.append({
|
|
493
|
-
"id": f"{provider}:{
|
|
494
|
-
"name": f"{provider.title()} · {
|
|
512
|
+
"id": f"{provider}:{custom_model}",
|
|
513
|
+
"name": f"{provider.title()} · {custom_model}",
|
|
495
514
|
"provider": provider,
|
|
496
515
|
"tag": "cloud",
|
|
497
516
|
"available": bool(os.getenv(config["env_key"]) or config.get("api_key_fallback")),
|
|
498
517
|
"requires": None,
|
|
499
518
|
**source_metadata_for_model(
|
|
500
519
|
provider,
|
|
501
|
-
{
|
|
520
|
+
{
|
|
521
|
+
"id": custom_model,
|
|
522
|
+
"name": f"{provider.title()} · {custom_model}",
|
|
523
|
+
"family": provider.title(),
|
|
524
|
+
},
|
|
502
525
|
local_server=provider in local_server_providers,
|
|
503
526
|
),
|
|
504
527
|
})
|
|
@@ -551,7 +574,7 @@ class LLMRouter:
|
|
|
551
574
|
print(f"⚠️ VLM chat template fallback: {e}")
|
|
552
575
|
return self._build_prompt(message, context, processor)
|
|
553
576
|
|
|
554
|
-
def _unpack_local_cache(self, cached:
|
|
577
|
+
def _unpack_local_cache(self, cached: Any) -> Tuple[Any, Any, Any, str]:
|
|
555
578
|
model, tokenizer, draft_model = cached[:3]
|
|
556
579
|
loader_kind = str(cached[3]) if len(cached) > 3 else "mlx_vlm"
|
|
557
580
|
return model, tokenizer, draft_model, loader_kind
|
|
@@ -622,8 +645,9 @@ class LLMRouter:
|
|
|
622
645
|
loop = asyncio.get_event_loop()
|
|
623
646
|
|
|
624
647
|
def _gen():
|
|
625
|
-
import mlx.core as mx
|
|
626
|
-
|
|
648
|
+
import mlx.core as mx # type: ignore[no-redef]
|
|
649
|
+
|
|
650
|
+
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
627
651
|
if use_vlm:
|
|
628
652
|
from mlx_vlm import generate as vlm_gen
|
|
629
653
|
return vlm_gen(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
@@ -679,11 +703,12 @@ class LLMRouter:
|
|
|
679
703
|
else self._build_prompt(message, context, tokenizer)
|
|
680
704
|
)
|
|
681
705
|
loop = asyncio.get_event_loop()
|
|
682
|
-
queue = asyncio.Queue()
|
|
706
|
+
queue: "asyncio.Queue[Any]" = asyncio.Queue()
|
|
683
707
|
|
|
684
708
|
def _stream():
|
|
685
|
-
import mlx.core as mx
|
|
686
|
-
|
|
709
|
+
import mlx.core as mx # type: ignore[no-redef]
|
|
710
|
+
|
|
711
|
+
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
687
712
|
try:
|
|
688
713
|
if use_vlm:
|
|
689
714
|
from mlx_vlm import stream_generate as vlm_stream
|
|
@@ -806,8 +831,9 @@ class LLMRouter:
|
|
|
806
831
|
|
|
807
832
|
loop = asyncio.get_event_loop()
|
|
808
833
|
def _gen():
|
|
809
|
-
import mlx.core as mx
|
|
810
|
-
|
|
834
|
+
import mlx.core as mx # type: ignore[no-redef]
|
|
835
|
+
|
|
836
|
+
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
811
837
|
if loader_kind == "mlx_vlm":
|
|
812
838
|
from mlx_vlm import generate as vlm_gen
|
|
813
839
|
return vlm_gen(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
@@ -885,11 +911,12 @@ class LLMRouter:
|
|
|
885
911
|
prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
886
912
|
|
|
887
913
|
loop = asyncio.get_event_loop()
|
|
888
|
-
queue = asyncio.Queue()
|
|
914
|
+
queue: "asyncio.Queue[Any]" = asyncio.Queue()
|
|
889
915
|
|
|
890
916
|
def _stream():
|
|
891
|
-
import mlx.core as mx
|
|
892
|
-
|
|
917
|
+
import mlx.core as mx # type: ignore[no-redef]
|
|
918
|
+
|
|
919
|
+
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
893
920
|
try:
|
|
894
921
|
if loader_kind == "mlx_vlm":
|
|
895
922
|
from mlx_vlm import stream_generate as vlm_stream
|
|
@@ -15,7 +15,9 @@ def build_access_runtime(
|
|
|
15
15
|
request_type: Any,
|
|
16
16
|
load_users: Callable[[], Dict],
|
|
17
17
|
get_session_email: Callable[[str], Optional[str]],
|
|
18
|
-
|
|
18
|
+
# Optional in and out: an anonymous request has no email, and an email
|
|
19
|
+
# with no account has no id.
|
|
20
|
+
user_id_for_email: Callable[[Dict, Optional[str]], Optional[str]],
|
|
19
21
|
) -> Dict[str, Any]:
|
|
20
22
|
"""Build user/admin access helpers without changing legacy call signatures."""
|
|
21
23
|
|