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.
- {uro_cli-0.2.0 → uro_cli-0.5.0}/PKG-INFO +6 -5
- {uro_cli-0.2.0 → uro_cli-0.5.0}/pyproject.toml +5 -4
- uro_cli-0.5.0/tests/test_cli_scaffold.py +89 -0
- uro_cli-0.5.0/tests/test_registry_router.py +123 -0
- {uro_cli-0.2.0 → uro_cli-0.5.0}/uro_cli/main.py +353 -18
- {uro_cli-0.2.0 → uro_cli-0.5.0}/uro_cli/wiring.py +1 -0
- uro_cli-0.2.0/tests/test_cli_scaffold.py +0 -8
- {uro_cli-0.2.0 → uro_cli-0.5.0}/.gitignore +0 -0
- {uro_cli-0.2.0 → uro_cli-0.5.0}/LICENSE +0 -0
- {uro_cli-0.2.0 → uro_cli-0.5.0}/README.md +0 -0
- {uro_cli-0.2.0 → uro_cli-0.5.0}/tests/test_wiring.py +0 -0
- {uro_cli-0.2.0 → uro_cli-0.5.0}/uro_cli/__init__.py +0 -0
- {uro_cli-0.2.0 → uro_cli-0.5.0}/uro_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: uro-cli
|
|
3
|
-
Version: 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.
|
|
14
|
-
Requires-Dist: uro-core[llm,postgres]==0.
|
|
15
|
-
Requires-Dist: uro-server==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.
|
|
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.
|
|
17
|
-
"uro-server==0.
|
|
18
|
-
"typer>=0.
|
|
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
|
-
|
|
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
|
-
#
|
|
565
|
-
#
|
|
566
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|