uro-cli 0.2.0__tar.gz → 0.5.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: uro-cli
3
- Version: 0.2.0
3
+ Version: 0.5.0
4
4
  Summary: Uro Engine reference client — play, dry-run, and dev tools against the engine
5
5
  Project-URL: Homepage, https://github.com/cupskeee/uro
6
6
  Project-URL: Repository, https://github.com/cupskeee/uro
@@ -9,10 +9,11 @@ Author: cupskeee
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.12
12
+ Requires-Dist: python-dotenv>=1.0
12
13
  Requires-Dist: rich>=13.9
13
- Requires-Dist: typer>=0.15
14
- Requires-Dist: uro-core[llm,postgres]==0.2.0
15
- Requires-Dist: uro-server==0.2.0
14
+ Requires-Dist: typer>=0.27.2
15
+ Requires-Dist: uro-core[llm,postgres]==0.5.0
16
+ Requires-Dist: uro-server==0.5.0
16
17
  Requires-Dist: websockets>=16.1
17
18
  Description-Content-Type: text/markdown
18
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uro-cli"
3
- version = "0.2.0"
3
+ version = "0.5.0"
4
4
  description = "Uro Engine reference client — play, dry-run, and dev tools against the engine"
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
@@ -13,11 +13,12 @@ requires-python = ">=3.12"
13
13
  # pair a new uro-cli with an old uro-core; `just release` bumps all three together, and the
14
14
  # `{workspace = true}` sources make a drifted pin fail `uv sync` locally.
15
15
  dependencies = [
16
- "uro-core[postgres,llm]==0.2.0",
17
- "uro-server==0.2.0",
18
- "typer>=0.15",
16
+ "uro-core[postgres,llm]==0.5.0",
17
+ "uro-server==0.5.0",
18
+ "typer>=0.27.2",
19
19
  "rich>=13.9",
20
20
  "websockets>=16.1",
21
+ "python-dotenv>=1.0",
21
22
  ]
22
23
 
23
24
  [project.urls]
@@ -0,0 +1,89 @@
1
+ from typer.testing import CliRunner
2
+ from uro_cli.main import app
3
+
4
+
5
+ def test_cli_version_runs() -> None:
6
+ result = CliRunner().invoke(app, ["version"])
7
+ assert result.exit_code == 0
8
+ assert "uro-core" in result.output
9
+
10
+
11
+ def test_provider_codex_login(monkeypatch) -> None: # type: ignore[no-untyped-def]
12
+ """The whole `uro provider codex-login` flow, with codex_auth + the store mocked."""
13
+ from uro_cli import main
14
+ from uro_core.providers import codex_auth
15
+
16
+ async def dev(**kw: object) -> dict[str, object]:
17
+ return {
18
+ "device_auth_id": "d",
19
+ "user_code": "J7DE-8NXJS",
20
+ "verification_uri": "https://auth.openai.com/codex/device",
21
+ "interval": 0, # instant poll — no real waiting in the test
22
+ "expires_in": 10,
23
+ }
24
+
25
+ async def poll(dev_id: str, code: str, **kw: object) -> dict[str, str]:
26
+ return {"authorization_code": "AC", "code_verifier": "V"}
27
+
28
+ async def exch(code: str, verifier: str, **kw: object) -> dict[str, str]:
29
+ return {"access_token": "AT", "refresh_token": "RT"}
30
+
31
+ async def disc(token: str, **kw: object) -> list[dict[str, str]]:
32
+ return [{"id": "gpt-5-codex", "modality": "chat"}]
33
+
34
+ monkeypatch.setattr(codex_auth, "request_device_code", dev)
35
+ monkeypatch.setattr(codex_auth, "poll_device_auth", poll)
36
+ monkeypatch.setattr(codex_auth, "exchange_code", exch)
37
+ monkeypatch.setattr(codex_auth, "discover_codex_models", disc)
38
+
39
+ class _Store:
40
+ async def add_credential(self, **kw: object) -> str:
41
+ assert kw["auth_mode"] == "oauth_device" and kw["provider"] == "codex"
42
+ return "cred-1"
43
+
44
+ async def add_connection(self, **kw: object) -> str:
45
+ return "conn-1"
46
+
47
+ async def set_connection_models(self, cid: str, models: object) -> bool:
48
+ return True
49
+
50
+ async def close(self) -> None:
51
+ pass
52
+
53
+ async def _noop(store: object) -> None:
54
+ return None
55
+
56
+ monkeypatch.setattr(main, "build_store", lambda: _Store())
57
+ monkeypatch.setattr(main, "connect_store", _noop)
58
+
59
+ result = CliRunner().invoke(app, ["provider", "codex-login", "--no-browser"])
60
+ assert result.exit_code == 0, result.output
61
+ assert "J7DE-8NXJS" in result.output # the displayed device code
62
+ assert "connected: conn-1" in result.output
63
+ assert "gpt-5-codex" in result.output # discovered model listed
64
+
65
+
66
+ def test_provider_bind_rejects_a_chat_model_for_the_embedder(monkeypatch) -> None: # type: ignore[no-untyped-def]
67
+ """CLI parity with the server's embedder-modality guard (review): a chat model (codex is always
68
+ chat) can't back the embedder role."""
69
+ from uro_cli import main
70
+ from uro_core.ports.model_registry import ModelConnection
71
+
72
+ class _Store:
73
+ async def get_connection(self, cid: str) -> ModelConnection:
74
+ return ModelConnection(id=cid, name="cx", provider="codex", auth_id="a1")
75
+
76
+ async def set_role_binding(self, *a: object, **kw: object) -> None:
77
+ raise AssertionError("should not bind — the guard must reject first")
78
+
79
+ async def close(self) -> None:
80
+ pass
81
+
82
+ async def _noop(store: object) -> None:
83
+ return None
84
+
85
+ monkeypatch.setattr(main, "build_store", lambda: _Store())
86
+ monkeypatch.setattr(main, "connect_store", _noop)
87
+ result = CliRunner().invoke(app, ["provider", "bind", "embedder", "conn-1", "gpt-5-codex"])
88
+ assert result.exit_code == 1
89
+ assert "embedding model" in result.output
@@ -0,0 +1,123 @@
1
+ """D-47 (docs/20): `build_router_from_registry` resolves DB model-connection registry rows into a
2
+ ProviderRouter. The resolution logic is pure, so it's tested against an in-memory fake registry (no
3
+ DB) — the DB CRUD/crypto themselves live in uro-core's test_model_registry.py.
4
+ """
5
+
6
+ import pytest
7
+ from uro_cli.wiring import build_router_from_registry
8
+ from uro_core.ports.model_registry import ModelConnection, RoleBinding
9
+ from uro_core.providers.adapters.anthropic import AnthropicProvider
10
+ from uro_core.providers.adapters.openai_compat import OpenAICompatProvider
11
+ from uro_core.providers.adapters.stub import StubProvider
12
+
13
+
14
+ class FakeRegistry:
15
+ """The subset of the ModelRegistry port that build_router_from_registry actually calls."""
16
+
17
+ def __init__(self, connections=None, bindings=None, secrets=None): # type: ignore[no-untyped-def]
18
+ self._connections = connections or []
19
+ self._bindings = bindings or []
20
+ self._secrets = secrets or {}
21
+
22
+ async def list_role_bindings(self): # type: ignore[no-untyped-def]
23
+ return self._bindings
24
+
25
+ async def list_connections(self): # type: ignore[no-untyped-def]
26
+ return self._connections
27
+
28
+ async def get_secret(self, credential_id): # type: ignore[no-untyped-def]
29
+ return self._secrets.get(credential_id)
30
+
31
+
32
+ async def test_empty_registry_returns_none() -> None:
33
+ # No bindings → None, so `serve` falls back to the --provider/uro.toml seed.
34
+ assert await build_router_from_registry(FakeRegistry()) is None
35
+
36
+
37
+ async def test_resolves_roles_with_default_fallback() -> None:
38
+ conn = ModelConnection(id="c1", name="stub", provider="stub")
39
+ reg = FakeRegistry(
40
+ connections=[conn],
41
+ bindings=[
42
+ RoleBinding(role="default", connection_id="c1", model="stub"),
43
+ RoleBinding(role="narrator", connection_id="c1", model="stub"),
44
+ ],
45
+ )
46
+ router = await build_router_from_registry(reg)
47
+ assert router is not None
48
+ assert isinstance(router._provider_for("narrator"), StubProvider) # explicitly bound
49
+ assert isinstance(router._provider_for("judge"), StubProvider) # unbound → the default binding
50
+
51
+
52
+ async def test_disabled_or_missing_connection_is_skipped() -> None:
53
+ reg = FakeRegistry(
54
+ connections=[
55
+ ModelConnection(id="c1", name="def", provider="stub"),
56
+ ModelConnection(id="c2", name="off", provider="stub", is_enabled=False),
57
+ ],
58
+ bindings=[
59
+ RoleBinding(role="default", connection_id="c1", model="stub"),
60
+ RoleBinding(role="narrator", connection_id="c2", model="stub"), # disabled → skip
61
+ RoleBinding(role="planner", connection_id="ghost", model="stub"), # missing → skip
62
+ ],
63
+ )
64
+ router = await build_router_from_registry(reg)
65
+ assert router is not None
66
+ # Both skipped roles fall through to the enabled default.
67
+ assert router._provider_for("narrator") is router._provider_for("judge")
68
+ assert router._provider_for("planner") is router._provider_for("judge")
69
+
70
+
71
+ async def test_builds_providers_with_decrypted_creds_and_base_url() -> None:
72
+ reg = FakeRegistry(
73
+ connections=[
74
+ ModelConnection(id="c1", name="oai", provider="openai", auth_id="k1"),
75
+ ModelConnection(
76
+ id="c2", name="ant", provider="anthropic", auth_id="k2", base_url="https://proxy"
77
+ ),
78
+ ],
79
+ bindings=[
80
+ RoleBinding(role="default", connection_id="c1", model="gpt-4o"),
81
+ RoleBinding(role="dialogue", connection_id="c2", model="claude-sonnet-5"),
82
+ ],
83
+ secrets={"k1": ("sk-oai", None), "k2": ("sk-ant", None)},
84
+ )
85
+ router = await build_router_from_registry(reg)
86
+ assert router is not None
87
+ oai = router._provider_for("default")
88
+ ant = router._provider_for("dialogue")
89
+ assert isinstance(oai, OpenAICompatProvider)
90
+ assert oai._api_key == "sk-oai" and oai._model == "gpt-4o" # decrypted key injected
91
+ assert isinstance(ant, AnthropicProvider)
92
+ assert ant._api_key == "sk-ant" and ant._base_url == "https://proxy" # per-connection override
93
+
94
+
95
+ async def test_openai_compat_without_base_url_is_an_error() -> None:
96
+ reg = FakeRegistry(
97
+ connections=[ModelConnection(id="c1", name="compat", provider="openai_compat")],
98
+ bindings=[RoleBinding(role="default", connection_id="c1", model="m")],
99
+ )
100
+ with pytest.raises(ValueError):
101
+ await build_router_from_registry(reg)
102
+
103
+
104
+ async def test_bindings_without_default_is_refused() -> None:
105
+ # A role bound but no `default` → any unbound role would KeyError mid-beat; refuse at build.
106
+ reg = FakeRegistry(
107
+ connections=[ModelConnection(id="c1", name="stub", provider="stub")],
108
+ bindings=[RoleBinding(role="narrator", connection_id="c1", model="stub")],
109
+ )
110
+ with pytest.raises(ValueError):
111
+ await build_router_from_registry(reg)
112
+
113
+
114
+ async def test_all_roles_bound_without_default_is_ok() -> None:
115
+ # Every non-default role bound → no default needed → a valid router (no crash path).
116
+ roles = ["narrator", "extractor", "planner", "embedder", "dialogue", "judge"]
117
+ reg = FakeRegistry(
118
+ connections=[ModelConnection(id="c1", name="stub", provider="stub")],
119
+ bindings=[RoleBinding(role=r, connection_id="c1", model="stub") for r in roles],
120
+ )
121
+ router = await build_router_from_registry(reg)
122
+ assert router is not None
123
+ assert isinstance(router._provider_for("narrator"), StubProvider)
@@ -6,25 +6,38 @@ Phase 0 subset: version, db migrate, world new, play.
6
6
  from __future__ import annotations
7
7
 
8
8
  import asyncio
9
+ import contextlib
9
10
  import sys
11
+ import time
12
+ import webbrowser
10
13
  from typing import Any
11
14
 
12
15
  import typer
16
+ from dotenv import find_dotenv, load_dotenv
13
17
  from uro_core.adapters.postgres.store import PostgresEventStore
14
18
  from uro_core.pipeline.engine import Engine
19
+ from uro_core.ports.model_registry import ROLES
20
+ from uro_core.providers.router import ProviderRouter
15
21
  from uro_core.rulesets.base import CharSpec
16
22
  from uro_core.rulesets.rng import Rng
17
23
  from uro_core.timeline.models import World
18
24
 
19
25
  from uro_cli.wiring import (
20
- DatabaseUnavailable,
21
26
  build_router,
27
+ build_router_from_registry,
22
28
  build_ruleset,
23
29
  build_store,
24
30
  connect_store,
25
31
  parse_role_models,
26
32
  )
27
33
 
34
+ # Load a local `.env` (found from the CURRENT WORKING DIR, walking up) into the environment before
35
+ # any command reads it — so `URO_SECRET_KEY` / provider keys / `URO_DATABASE_URL` need only live in
36
+ # `.env`, not be exported by hand. `usecwd=True` so it finds the `.env` where you RUN `uro`, not the
37
+ # installed package dir. Precedence preserved: an exported env var wins (dotenv never overrides). No
38
+ # `.env` → find_dotenv returns "" → a harmless no-op.
39
+ load_dotenv(find_dotenv(usecwd=True))
40
+
28
41
  app = typer.Typer(no_args_is_help=True, help="Uro Engine — reference client.")
29
42
  db_app = typer.Typer(no_args_is_help=True, help="Database management.")
30
43
  world_app = typer.Typer(no_args_is_help=True, help="World and campaign management.")
@@ -36,12 +49,25 @@ codex_app = typer.Typer(
36
49
  token_app = typer.Typer(
37
50
  no_args_is_help=True, help="Runtime session tokens against a running server (docs/18 B10)."
38
51
  )
52
+ provider_app = typer.Typer(
53
+ no_args_is_help=True,
54
+ help="Model connections — the DB-backed LLM provider registry (D-47, docs/20).",
55
+ )
56
+ outbox_app = typer.Typer(
57
+ no_args_is_help=True,
58
+ help="The transactional outbox — publish committed beats to other processes (D-57).",
59
+ )
39
60
  app.add_typer(db_app, name="db")
40
61
  app.add_typer(world_app, name="world")
41
62
  app.add_typer(branch_app, name="branch")
42
63
  app.add_typer(campaign_app, name="campaign")
43
64
  app.add_typer(codex_app, name="codex")
44
65
  app.add_typer(token_app, name="token")
66
+ app.add_typer(provider_app, name="provider")
67
+ app.add_typer(outbox_app, name="outbox")
68
+
69
+ # The engine roles a connection can back (resolved by the ProviderRouter) — shared with the server.
70
+ _ROLES = tuple(sorted(ROLES))
45
71
 
46
72
  PARTICIPANT = "player-1" # Phase 0 is single-player; participants arrive in Phase 5.
47
73
 
@@ -55,6 +81,25 @@ def _run_async(coro_factory) -> None: # type: ignore[no-untyped-def]
55
81
  raise typer.Exit(1) from exc
56
82
 
57
83
 
84
+ def _resolve_router(store: PostgresEventStore, provider: str, model: str | None): # type: ignore[no-untyped-def]
85
+ """Resolve the ProviderRouter for `serve`: the DB model-connection registry (D-47) wins if it
86
+ has usable bindings, else the `--provider`/`uro.toml` seed. This first DB touch also serves as
87
+ the preflight. Returns (router, source_label). Raises DatabaseUnavailable if the DB is down and
88
+ RuntimeError/ValueError if a CONFIGURED registry can't be built (bad KEK, missing key)."""
89
+
90
+ async def _read() -> ProviderRouter | None:
91
+ await connect_store(store)
92
+ try:
93
+ return await build_router_from_registry(store)
94
+ finally:
95
+ await store.close()
96
+
97
+ registry_router = asyncio.run(_read())
98
+ if registry_router is not None:
99
+ return registry_router, "DB model-connection registry (D-47)"
100
+ return build_router(provider, model), f"seed (provider={provider})"
101
+
102
+
58
103
  async def _world_or_exit(store: PostgresEventStore, ident: str) -> World:
59
104
  """Resolve a world by name (preferred) or id, or exit 1 with a clean message."""
60
105
  world = await store.get_world_by_name(ident) or await store.get_world(ident)
@@ -501,6 +546,14 @@ def serve(
501
546
  "(consensus). UNSET = auto (solo for 1 token, party for >1). Setting it EXPLICITLY binds "
502
547
  "that shape even for a 1-token launch, so a runtime-added player still gets turns (D-39).",
503
548
  ),
549
+ cors_origin: list[str] = typer.Option( # noqa: B008
550
+ None,
551
+ "--cors-origin",
552
+ help="allow a browser origin to call this server, e.g. --cors-origin http://localhost:5173 "
553
+ "(repeatable). REQUIRED for the uro-loom web console (a cross-origin SPA); the browser "
554
+ "blocks calls with no CORS header. Pass '*' to allow any origin (dev only; disables "
555
+ "credentials). Omit for CLI/embedded use.",
556
+ ),
504
557
  ) -> None:
505
558
  """Run the Uro server (docs/08): a thin FastAPI shell over the engine. Each --token maps to
506
559
  a participant; with no --token, a single 'local' token binds player-1 (the solo dev loop).
@@ -530,7 +583,16 @@ def serve(
530
583
  tokens[tok] = name if sep else f"operator-{j + 1}"
531
584
  admin_tokens.add(tok)
532
585
  store = build_store()
533
- engine = Engine(store, build_router(provider, model), ruleset=build_ruleset(ruleset))
586
+ # Resolve the ProviderRouter: the DB model-connection registry (D-47) wins if it has usable
587
+ # bindings, else the --provider/uro.toml seed. This first DB touch doubles as the preflight (a
588
+ # down DB → the clean docker-first hint, not a uvicorn startup traceback). A CONFIGURED-but-
589
+ # broken registry (bad KEK, missing key) fails LOUDLY rather than silently serving the stub.
590
+ try:
591
+ router, llm_source = _resolve_router(store, provider, model)
592
+ except (RuntimeError, ValueError) as exc: # incl. DatabaseUnavailable (a RuntimeError subclass)
593
+ typer.echo(f"error: {exc}", err=True)
594
+ raise typer.Exit(1) from exc
595
+ engine = Engine(store, router, ruleset=build_ruleset(ruleset))
534
596
  # Pick the turn shape (OQ-7, D-31/D-38/D-39). UNSET (--arbiter not given) is AUTO: solo for one
535
597
  # token (the dev loop → SoloArbiter), party for >1. Set EXPLICITLY, the shape binds regardless
536
598
  # of launch token count, so a player minted+joined at RUNTIME still gets real turns (D-39)
@@ -548,8 +610,8 @@ def serve(
548
610
  )
549
611
  else:
550
612
  arbiter = shapes[arbiter_kind]()
551
- deps = engine_deps(store, engine, tokens, admin_tokens)
552
- fastapi_app = create_app(deps, arbiter=arbiter)
613
+ deps = engine_deps(store, engine, tokens, admin_tokens, router=router)
614
+ fastapi_app = create_app(deps, arbiter=arbiter, cors_origins=cors_origin or None)
553
615
 
554
616
  @fastapi_app.on_event("startup")
555
617
  async def _startup() -> None:
@@ -561,27 +623,19 @@ def serve(
561
623
  async def _shutdown() -> None:
562
624
  await store.close()
563
625
 
564
- # Preflight the DB here so a down database gets the same clean docker-first hint every other
565
- # command gives (via connect_store), instead of a uvicorn "Application startup failed"
566
- # traceback. The live pool is (re)opened by the startup hook on uvicorn's own event loop — an
567
- # asyncpg pool is bound to the loop that created it, so this throwaway probe is closed at once.
568
- async def _preflight() -> None:
569
- await connect_store(store)
570
- await store.close()
571
-
572
- try:
573
- asyncio.run(_preflight())
574
- except DatabaseUnavailable as exc:
575
- typer.echo(f"error: {exc}", err=True)
576
- raise typer.Exit(1) from exc
577
-
626
+ # (The DB preflight is folded into _resolve_router above — its connect_store gives the same
627
+ # clean docker-first hint on a down DB, and the pool it opens is closed before uvicorn starts;
628
+ # the startup hook re-opens one on uvicorn's own loop.)
578
629
  shape = type(arbiter).__name__ if arbiter is not None else "solo"
579
630
  typer.echo(
580
631
  f"uro server on ws://{host}:{port} "
581
632
  f"({len(tokens)} token(s), provider={provider}, arbiter={shape})"
582
633
  )
634
+ typer.echo(f" LLM: {llm_source}")
583
635
  for t, p in tokens.items():
584
636
  typer.echo(f" token {t!r} → {p}")
637
+ if cors_origin:
638
+ typer.echo(f" CORS: allowing {', '.join(cors_origin)}")
585
639
  uvicorn.run(fastapi_app, host=host, port=port, log_level="warning")
586
640
 
587
641
 
@@ -684,6 +738,67 @@ def _server_post(server: str, path: str, token: str, body: dict[str, Any]) -> di
684
738
  raise typer.Exit(1) from exc
685
739
 
686
740
 
741
+ @outbox_app.command("status")
742
+ def outbox_status() -> None:
743
+ """How many committed beats are waiting to be published."""
744
+
745
+ async def _run() -> None:
746
+ store = build_store()
747
+ await connect_store(store)
748
+ try:
749
+ typer.echo(f"pending: {await store.outbox_pending()}")
750
+ finally:
751
+ await store.close()
752
+
753
+ _run_async(_run)
754
+
755
+
756
+ @outbox_app.command("relay")
757
+ def outbox_relay(
758
+ nats_url: str = typer.Option(..., "--nats", help="NATS server URL, e.g. nats://localhost:4222"),
759
+ stream: str = typer.Option("URO", help="JetStream stream (created if missing)"),
760
+ subject_prefix: str = typer.Option("uro", help="subjects are <prefix>.<world>.<branch>"),
761
+ once: bool = typer.Option(False, "--once", help="drain what is pending, then exit"),
762
+ interval: float = typer.Option(1.0, help="seconds between polls (without --once)"),
763
+ batch: int = typer.Option(100, min=1, help="commits claimed per transaction"),
764
+ ) -> None:
765
+ """Publish committed beats to NATS JetStream (D-57). At-least-once from the outbox; JetStream
766
+ drops a re-sent commit (Nats-Msg-Id = commit id). Run ONE relay to keep each branch in commit
767
+ order (there is no global order across branches); several are safe — no row is published
768
+ twice — but may interleave a branch. A publish error is retried with backoff (`--once` exits
769
+ instead)."""
770
+ try:
771
+ from uro_core.adapters.nats import NatsJetStreamPublisher
772
+ except ImportError as exc:
773
+ typer.echo("the NATS relay needs `pip install 'uro-core[nats]'`", err=True)
774
+ raise typer.Exit(1) from exc
775
+ from uro_core.outbox import relay_forever, relay_outbox
776
+
777
+ async def _run() -> None:
778
+ store = build_store()
779
+ await connect_store(store)
780
+ publisher = NatsJetStreamPublisher(nats_url, stream=stream, subject_prefix=subject_prefix)
781
+ await publisher.connect()
782
+ try:
783
+ if once: # a one-shot drain: an error exits non-zero (right for scripts and cron)
784
+ sent = await relay_outbox(store, publisher, limit=batch)
785
+ typer.echo(f"published {sent} commit(s)")
786
+ return
787
+ # the daemon: a publish error is logged and retried with backoff, never fatal
788
+ await relay_forever(
789
+ store,
790
+ publisher,
791
+ interval=interval,
792
+ limit=batch,
793
+ on_published=lambda n: typer.echo(f"published {n} commit(s)"),
794
+ )
795
+ finally:
796
+ await publisher.close()
797
+ await store.close()
798
+
799
+ _run_async(_run)
800
+
801
+
687
802
  @token_app.command("mint")
688
803
  def token_mint(
689
804
  campaign_id: str,
@@ -1088,3 +1203,223 @@ def codex_list(
1088
1203
  typer.echo(f"- {n.text}{tag}")
1089
1204
 
1090
1205
  _run_async(_run)
1206
+
1207
+
1208
+ # --- Model-connection registry (D-47, docs/20) — direct-DB config of the LLM provider registry.
1209
+ # A server API mirror (for uro-loom / remote clients) lands in the next slice; today these edit the
1210
+ # instance's DB directly (operator with DB access), like `uro world`/`uro campaign`.
1211
+
1212
+
1213
+ @provider_app.command("add")
1214
+ def provider_add(
1215
+ name: str = typer.Argument(..., help="a display label for this connection"),
1216
+ provider: str = typer.Option(
1217
+ ..., "--provider", help="provider kind: openai | anthropic | openai_compat | local | stub"
1218
+ ),
1219
+ base_url: str = typer.Option(None, "--base-url", help="per-connection endpoint override"),
1220
+ api_key: str = typer.Option(
1221
+ None,
1222
+ "--api-key",
1223
+ help="an API key to store ENCRYPTED (needs URO_SECRET_KEY) — creates + links a credential",
1224
+ ),
1225
+ credential: str = typer.Option(
1226
+ None, "--credential", help="reuse an EXISTING credential id instead of --api-key"
1227
+ ),
1228
+ ) -> None:
1229
+ """Register a model connection (a provider endpoint). With --api-key it also stores an encrypted
1230
+ credential and links it; a keyless provider (local/stub) needs neither."""
1231
+
1232
+ async def _run() -> None:
1233
+ if api_key and credential:
1234
+ raise ValueError("pass either --api-key or --credential, not both")
1235
+ store = build_store()
1236
+ await connect_store(store)
1237
+ try:
1238
+ auth_id: str | None = None
1239
+ if api_key:
1240
+ auth_id = await store.add_credential(provider=provider, access_token=api_key)
1241
+ elif credential:
1242
+ if all(c.id != credential for c in await store.list_credentials()):
1243
+ raise ValueError(f"no such credential: {credential}")
1244
+ auth_id = credential
1245
+ cid = await store.add_connection(
1246
+ name=name, provider=provider, base_url=base_url or None, auth_id=auth_id
1247
+ )
1248
+ finally:
1249
+ await store.close()
1250
+ typer.echo(f"connection: {cid} ({name} → {provider})")
1251
+ if auth_id:
1252
+ typer.echo(f"credential: {auth_id} (encrypted at rest)")
1253
+ # Steer toward binding `default` FIRST — a registry with role bindings but no `default`
1254
+ # is refused at serve/reload (unbound roles would crash a beat; holistic review).
1255
+ typer.echo(f"\nbind the default role: uro provider bind default {cid} <model>")
1256
+
1257
+ _run_async(_run)
1258
+
1259
+
1260
+ @provider_app.command("list")
1261
+ def provider_list() -> None:
1262
+ """Show the registry: connections, role bindings, and credential metadata (never secrets)."""
1263
+
1264
+ async def _run() -> None:
1265
+ store = build_store()
1266
+ await connect_store(store)
1267
+ try:
1268
+ connections = await store.list_connections()
1269
+ bindings = await store.list_role_bindings()
1270
+ credentials = await store.list_credentials()
1271
+ finally:
1272
+ await store.close()
1273
+ typer.echo("connections:")
1274
+ if not connections:
1275
+ typer.echo(" (none — add one with `uro provider add`)")
1276
+ for c in connections:
1277
+ base = f" @{c.base_url}" if c.base_url else ""
1278
+ auth = f" auth={c.auth_id}" if c.auth_id else " (keyless)"
1279
+ disabled = "" if c.is_enabled else " [disabled]"
1280
+ typer.echo(f" {c.id} {c.name} → {c.provider}{base}{auth}{disabled}")
1281
+ typer.echo("role bindings:")
1282
+ if not bindings:
1283
+ typer.echo(" (none — the server falls back to the --provider/uro.toml seed)")
1284
+ for b in bindings:
1285
+ typer.echo(f" {b.role:<9} → {b.connection_id} : {b.model}")
1286
+ if credentials:
1287
+ typer.echo("credentials:")
1288
+ for cr in credentials:
1289
+ has = " (access_token set)" if cr.has_access_token else ""
1290
+ typer.echo(f" {cr.id} {cr.provider} {cr.auth_mode}{has}")
1291
+
1292
+ _run_async(_run)
1293
+
1294
+
1295
+ @provider_app.command("rm")
1296
+ def provider_rm(
1297
+ connection_id: str = typer.Argument(..., help="the connection id to delete"),
1298
+ ) -> None:
1299
+ """Delete a connection (its role bindings cascade → those roles fall back to `default`)."""
1300
+
1301
+ async def _run() -> None:
1302
+ store = build_store()
1303
+ await connect_store(store)
1304
+ try:
1305
+ ok = await store.delete_connection(connection_id)
1306
+ finally:
1307
+ await store.close()
1308
+ typer.echo("deleted" if ok else f"no such connection: {connection_id}")
1309
+
1310
+ _run_async(_run)
1311
+
1312
+
1313
+ @provider_app.command("bind")
1314
+ def provider_bind(
1315
+ role: str = typer.Argument(..., help=f"one of: {', '.join(_ROLES)}"),
1316
+ connection_id: str = typer.Argument(...),
1317
+ model: str = typer.Argument(..., help="a model id offered by the connection's provider"),
1318
+ ) -> None:
1319
+ """Bind an engine role to a connection + model. `default` backs any unbound role."""
1320
+
1321
+ async def _run() -> None:
1322
+ from uro_core.providers.registry import classify_modality
1323
+
1324
+ if role not in _ROLES:
1325
+ raise ValueError(f"unknown role {role!r}; one of: {', '.join(_ROLES)}")
1326
+ store = build_store()
1327
+ await connect_store(store)
1328
+ try:
1329
+ conn = await store.get_connection(connection_id)
1330
+ if conn is None:
1331
+ raise ValueError(f"no such connection: {connection_id}")
1332
+ # Match the server's embedder-modality guard (D-47): the embedder needs an EMBEDDING
1333
+ # model, so reject a chat model here too — else the binding fails only at runtime, and a
1334
+ # codex/anthropic model (always "chat") could silently break semantic memory (review).
1335
+ if role == "embedder" and classify_modality(conn.provider, model) == "chat":
1336
+ raise ValueError(
1337
+ f"the embedder role needs an embedding model, not {model!r} "
1338
+ f"(a chat model on provider {conn.provider!r})"
1339
+ )
1340
+ await store.set_role_binding(role, connection_id, model)
1341
+ finally:
1342
+ await store.close()
1343
+ typer.echo(f"bound {role} → {connection_id} : {model}")
1344
+
1345
+ _run_async(_run)
1346
+
1347
+
1348
+ @provider_app.command("unbind")
1349
+ def provider_unbind(role: str = typer.Argument(..., help="the role to unbind")) -> None:
1350
+ """Remove a role binding (that role falls back to `default`)."""
1351
+
1352
+ async def _run() -> None:
1353
+ store = build_store()
1354
+ await connect_store(store)
1355
+ try:
1356
+ ok = await store.delete_role_binding(role)
1357
+ finally:
1358
+ await store.close()
1359
+ typer.echo("unbound" if ok else f"no binding for role: {role}")
1360
+
1361
+ _run_async(_run)
1362
+
1363
+
1364
+ @provider_app.command("codex-login")
1365
+ def provider_codex_login(
1366
+ name: str = typer.Option("codex", "--name", help="a display label for the connection"),
1367
+ no_browser: bool = typer.Option(
1368
+ False, "--no-browser", help="don't try to open the authorization page automatically"
1369
+ ),
1370
+ ) -> None:
1371
+ """Connect a Codex (ChatGPT-subscription) provider via OpenAI's OAuth device login. Prints a
1372
+ code to enter on the authorization page, then polls until you approve — no API key. UNOFFICIAL:
1373
+ uses a consumer ChatGPT subscription; may breach OpenAI's terms (needs URO_SECRET_KEY)."""
1374
+
1375
+ async def _run() -> None:
1376
+ from uro_core.providers import codex_auth
1377
+
1378
+ try:
1379
+ dc = await codex_auth.request_device_code()
1380
+ typer.echo(f"\n Enter this code on the OpenAI authorization page: {dc['user_code']}")
1381
+ typer.echo(f" Authorization page: {dc['verification_uri']}\n")
1382
+ if not no_browser:
1383
+ with contextlib.suppress(Exception): # opening a browser is best-effort
1384
+ webbrowser.open(dc["verification_uri"])
1385
+ typer.echo("Waiting for you to authorize with OpenAI… (Ctrl-C to cancel)")
1386
+
1387
+ deadline = time.monotonic() + dc["expires_in"]
1388
+ payload = None
1389
+ while time.monotonic() < deadline:
1390
+ await asyncio.sleep(dc["interval"])
1391
+ payload = await codex_auth.poll_device_auth(dc["device_auth_id"], dc["user_code"])
1392
+ if payload is not None:
1393
+ break
1394
+ if payload is None:
1395
+ raise ValueError("the login timed out before you approved the code — try again")
1396
+ tokens = await codex_auth.exchange_code(
1397
+ payload["authorization_code"], payload["code_verifier"]
1398
+ )
1399
+ except codex_auth.CodexAuthError as exc:
1400
+ raise ValueError(f"codex login failed: {exc}") from exc
1401
+
1402
+ store = build_store()
1403
+ await connect_store(store)
1404
+ try:
1405
+ cred_id = await store.add_credential(
1406
+ provider="codex",
1407
+ access_token=tokens["access_token"],
1408
+ refresh_token=tokens.get("refresh_token"),
1409
+ auth_mode="oauth_device",
1410
+ )
1411
+ conn_id = await store.add_connection(name=name, provider="codex", auth_id=cred_id)
1412
+ models: list[dict[str, str]] = []
1413
+ try:
1414
+ models = await codex_auth.discover_codex_models(tokens["access_token"])
1415
+ await store.set_connection_models(conn_id, models)
1416
+ except Exception:
1417
+ pass
1418
+ finally:
1419
+ await store.close()
1420
+ typer.echo(f"\nconnected: {conn_id} ({name} → codex)")
1421
+ if models:
1422
+ typer.echo("models: " + ", ".join(m["id"] for m in models))
1423
+ typer.echo(f"\nbind the default role: uro provider bind default {conn_id} <model>")
1424
+
1425
+ _run_async(_run)
@@ -23,6 +23,7 @@ from uro_core.providers.adapters.anthropic import AnthropicProvider
23
23
  from uro_core.providers.adapters.openai_compat import OpenAICompatProvider
24
24
  from uro_core.providers.adapters.stub import StubProvider
25
25
  from uro_core.providers.base import LLMProvider
26
+ from uro_core.providers.registry import build_router_from_registry as build_router_from_registry
26
27
  from uro_core.providers.router import ProviderRouter
27
28
  from uro_core.rulesets import registry
28
29
  from uro_core.rulesets.base import Ruleset
@@ -1,8 +0,0 @@
1
- from typer.testing import CliRunner
2
- from uro_cli.main import app
3
-
4
-
5
- def test_cli_version_runs() -> None:
6
- result = CliRunner().invoke(app, ["version"])
7
- assert result.exit_code == 0
8
- assert "uro-core" in result.output
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes