OSchema migration: consolidate 10 per-domain dispatchers into one oschema-driven rpc.sock #165

Closed
opened 2026-06-24 07:41:54 +00:00 by rawdaGastan · 0 comments
Member

Goal

Migrate hero_aibroker_server away from 10 hand-rolled per-domain JSON-RPC dispatchers and onto a single oschema-driven consolidated socket. Replace src/api_openrpc/ (~5,000 LOC of hand-maintained match-dispatchers + hand-edited specs/*.openrpc.json files + bespoke RpcError types) with one herolib_macros::openrpc_server! invocation that reads .oschema schemas and emits the dispatcher, typed Input/Output types, OpenRPC spec, and axum router as one unit.

Why

Adding a method today requires editing four places: the dispatcher (src/api_openrpc/<domain>/mod.rs), the spec (crates/hero_aibroker_server/specs/<domain>.openrpc.json), the SDK wrapper (hero_aibroker_sdk/src/lib.rs), and any consumer call sites — easy to drift, easy to forget one. Wire shapes are unchecked at compile time because params/results are pulled out of serde_json::Value. Specs and code can disagree silently. The oschema approach makes the schema the single source of truth: types, dispatcher, and spec are all generated from it; consumers regenerate from the same artifact.

Plan

1. Author oschema schemas

  • One oschema/<domain>/ directory per logical domain (meta, billing, chat, embedder, speech, images, video, memory, models, admin)
  • Within each, split shared enums (00_*.oschema) from rootobjects (10_*, 20_*) from the service block (90_rpc.oschema)
  • Translate every existing wire method to a typed signature: bare snake_case names (the macro forbids dots), one-input rule, named Input/Output structs
  • Mark ApiKey (SQLite) and Mother (hero_db) as [rootobject] so the macro generates their CRUD trait surface; back the generated sid: String with the existing primary keys
  • Mark client-input fields with ? so external OpenAI/OpenRouter/Anthropic clients can omit fields they don't care about; keep identity / discriminator / no-default fields required
  • Model opaque JSON passthrough fields (Anthropic system blocks, OpenAI tool_choice, OpenRouter pass-through bodies) as wide-optional typed structs covering every documented key (no any type per oschema rules)

2. Wire the macro

  • Add herolib_macros + herolib_oschema_server workspace deps from hero_lib (branch = "development"); drop the retired herolib_derive
  • Invoke openrpc_server!(spec = "oschema", service_toml = "service.toml", save_openrpc_dir = "openrpc/") from src/oschema_gen.rs
  • Emit generated specs to openrpc/openrpc_<domain>.json (checked into the tree so the SDK can consume them)

3. Implement the trait impls

  • src/rpc_impls/<domain>.rs × 10 — port each legacy handler body into the typed trait method generated by the macro
  • Carry shared state (Arc<RwLock>, Arc<...Service>, hero_db store, request logger, …) as struct fields on each <Domain>Service; constructor takes the AppState pieces it needs
  • Preserve legacy default semantics when unwrapping Option<T> inputs (e.g. input.temperature.unwrap_or(0.0))
  • Memory + video domains: mirror legacy stub behavior (method_not_implemented) — those subsystems aren't implemented in legacy either

4. Wire the consolidated socket in main.rs

  • Replace the 10-socket bind loop and domain_supervisor with tokio::spawn(oschema_gen::serve_domains_with(extra, ..)) binding ONE consolidated hero_aibroker/rpc.sock serving every domain at POST /api/{domain}/rpc
  • Wire SSE: shared Arc<SseBus>; one sse_router(bus, "/api/<dom>/events", "stream_id"|"topic") per streaming domain (chat, speech, video, admin); merge all into the extra router
  • Pass the bus + a stream-cancellation registry (Arc<DashMap<String, JoinHandle<()>>>) into chat / speech / video / admin services
  • Keep REST socket (rest.sock) + web socket (web_v1.sock) as-is — they're not migrated
  • Update service.toml: drop the 10 per-domain socket entries; keep rpc.sock + rest.sock + web_v1.sock

5. Update consumers

  • SDK (hero_aibroker_sdk): point openrpc_client! at ../hero_aibroker_server/openrpc/openrpc_<domain>.json; switch transport.call("ai.chat", ..) style to bare snake_case names; update doc-comment quick-starts
  • Dioxus admin UI (hero_aibroker_app): rewrite every rpc::rpc("dotted.name", ...) call site to snake_case; adjust result-shape unwraps where the macro now auto-flattens single-field {value: ...} outputs to bare lists / scalars
  • Askama admin proxy (hero_aibroker_admin): rewrite route_socket()route_domain(); forward every /rpc request to the consolidated rpc.sock at /api/{domain}/rpc instead of the per-domain legacy sockets
  • Test crate (hero_aibroker_test): retarget every test from per-domain sockets to the consolidated socket; update JSON-RPC bodies (snake_case method names, {"<param>": {..}} wrapping for struct inputs) and result-shape assertions for auto-flattened returns

6. Cut over and delete legacy

  • Verify all consumers pass against the consolidated socket only
  • Extract AppState + build_app_state out of src/api_openrpc/mod.rs into a standalone src/app_state.rs
  • Relocate src/api_openrpc/chat/translators.rs to src/api_openrouter/translators.rs (only the REST socket still consumes it)
  • Delete src/api_openrpc/ (entire directory)
  • Delete src/rpc.rs (legacy RpcError / JsonRpcRequest / JsonRpcResponse — replaced by herolib_oschema_server::RpcError)
  • Delete crates/hero_aibroker_server/specs/ (10 hand-maintained spec files — canonical specs now in openrpc/)
  • Remove DOMAIN_SOCKETS const, per-domain socket helpers, the 10 accept loops + domain_supervisor from main.rs
  • Update doc comments at the top of main.rs to describe the new 3-socket reality

Wire-name rename map

Every dotted method name becomes bare snake_case under its domain:

Legacy New
ai.chat / .completions / .messages / .responses / .stream.cancel chat / completions / messages / responses / stream_cancel
ai.tts / .transcribe / .transcribe_verbose tts / transcribe / transcribe_verbose
ai.embed / .rerank embed / rerank
ai.image image
ai.video.create / .get / .list_models video_create / video_get / video_list_models
memory.put/.get/.list/.delete/.search memory_put/memory_get/memory_list/memory_delete/memory_search
memory.file.put/.get/.list/.delete file_put/file_get/file_list/file_delete
embedder.get/.set/.reload/.ping embedder_get/embedder_set/embedder_reload/embedder_ping
apikeys.create / .disable / .list api_key_create / api_key_disable / api_key_list_full (generated CRUD)
mothers.add/.list/.remove/.update REMOVED — generated mother_set (empty sid creates) / mother_list_full / mother_delete / mother_set
mcp.* / providers.* / priority.* mcp_* / provider_* / priority_* (+ new priority_clear)
logs.* / metrics.* / activity.list / config.get logs_* / metrics_* / activity_list / config_get
models.add/update/delete/save/catalog.refresh model_add/update/delete/save + models_catalog_refresh (admin domain)
models.list/get/config/count/key/credits/... drop the prefix: list, get, config, count, key, credits, … (models domain)
meta.info / .sockets / .health info / sockets / health_detailed
billing.unbilled / .mark_billed unbilled / mark_billed
health / rpc.discover REMOVED — now macro-provided control plane in every domain

Wire-shape changes

  • The macro auto-flattens single-field {value: ...} outputs: billing.unbilled {"data": [...]} becomes bare [...]; billing.mark_billed {"marked": 0} becomes bare 0. Multi-field returns (e.g. meta.info's {version, uptime_seconds, is_mother, mother_count}) are unchanged.
  • Inputs that take a single struct parameter are wrapped: chat(request: ChatRequest) is called with {"request": {...}}, not {...} flat. The SDK regen handles this for in-repo callers.

Acceptance criteria

  • cargo build --bin hero_aibroker_server clean, zero warnings
  • --info --json reports 3 sockets (rpc.sock, rest.sock, web_v1.sock)
  • ls ~/hero/var/sockets/hero_aibroker/ shows exactly those 3 sockets — no per-domain subdirs
  • cargo test -p hero_aibroker_test passes end-to-end against the consolidated socket
  • SSE smoke test: streaming chat request returns a handle + events arrive at /api/chat/events
  • Admin UI works against the consolidated socket only
  • Workspace cargo build clean across SDK, admin UI, test crate
  • No references to api_openrpc:: module path (only stale doc comments allowed and to be scrubbed in a follow-up)

Out of scope

  • REST /api/v1/* socket (rest.sock) — stays hand-rolled axum
  • Real implementations for memory and video domains — both legacy and new return method_not_implemented; backend work is a separate ticket
## Goal Migrate `hero_aibroker_server` away from 10 hand-rolled per-domain JSON-RPC dispatchers and onto a single oschema-driven consolidated socket. Replace `src/api_openrpc/` (~5,000 LOC of hand-maintained match-dispatchers + hand-edited `specs/*.openrpc.json` files + bespoke `RpcError` types) with one `herolib_macros::openrpc_server!` invocation that reads `.oschema` schemas and emits the dispatcher, typed Input/Output types, OpenRPC spec, and axum router as one unit. ## Why Adding a method today requires editing four places: the dispatcher (`src/api_openrpc/<domain>/mod.rs`), the spec (`crates/hero_aibroker_server/specs/<domain>.openrpc.json`), the SDK wrapper (`hero_aibroker_sdk/src/lib.rs`), and any consumer call sites — easy to drift, easy to forget one. Wire shapes are unchecked at compile time because params/results are pulled out of `serde_json::Value`. Specs and code can disagree silently. The oschema approach makes the schema the single source of truth: types, dispatcher, and spec are all generated from it; consumers regenerate from the same artifact. ## Plan ### 1. Author oschema schemas - [ ] One `oschema/<domain>/` directory per logical domain (`meta`, `billing`, `chat`, `embedder`, `speech`, `images`, `video`, `memory`, `models`, `admin`) - [ ] Within each, split shared enums (`00_*.oschema`) from rootobjects (`10_*`, `20_*`) from the service block (`90_rpc.oschema`) - [ ] Translate every existing wire method to a typed signature: bare snake_case names (the macro forbids dots), one-input rule, named Input/Output structs - [ ] Mark `ApiKey` (SQLite) and `Mother` (hero_db) as `[rootobject]` so the macro generates their CRUD trait surface; back the generated `sid: String` with the existing primary keys - [ ] Mark client-input fields with `?` so external OpenAI/OpenRouter/Anthropic clients can omit fields they don't care about; keep identity / discriminator / no-default fields required - [ ] Model opaque JSON passthrough fields (Anthropic system blocks, OpenAI tool_choice, OpenRouter pass-through bodies) as wide-optional typed structs covering every documented key (no `any` type per oschema rules) ### 2. Wire the macro - [ ] Add `herolib_macros` + `herolib_oschema_server` workspace deps from `hero_lib` (`branch = "development"`); drop the retired `herolib_derive` - [ ] Invoke `openrpc_server!(spec = "oschema", service_toml = "service.toml", save_openrpc_dir = "openrpc/")` from `src/oschema_gen.rs` - [ ] Emit generated specs to `openrpc/openrpc_<domain>.json` (checked into the tree so the SDK can consume them) ### 3. Implement the trait impls - [ ] `src/rpc_impls/<domain>.rs` × 10 — port each legacy handler body into the typed trait method generated by the macro - [ ] Carry shared state (Arc<RwLock<Config>>, Arc<...Service>, hero_db store, request logger, …) as struct fields on each `<Domain>Service`; constructor takes the AppState pieces it needs - [ ] Preserve legacy default semantics when unwrapping `Option<T>` inputs (e.g. `input.temperature.unwrap_or(0.0)`) - [ ] Memory + video domains: mirror legacy stub behavior (`method_not_implemented`) — those subsystems aren't implemented in legacy either ### 4. Wire the consolidated socket in main.rs - [ ] Replace the 10-socket bind loop and `domain_supervisor` with `tokio::spawn(oschema_gen::serve_domains_with(extra, ..))` binding ONE consolidated `hero_aibroker/rpc.sock` serving every domain at `POST /api/{domain}/rpc` - [ ] Wire SSE: shared `Arc<SseBus>`; one `sse_router(bus, "/api/<dom>/events", "stream_id"|"topic")` per streaming domain (chat, speech, video, admin); merge all into the `extra` router - [ ] Pass the bus + a stream-cancellation registry (`Arc<DashMap<String, JoinHandle<()>>>`) into chat / speech / video / admin services - [ ] Keep REST socket (`rest.sock`) + web socket (`web_v1.sock`) as-is — they're not migrated - [ ] Update `service.toml`: drop the 10 per-domain socket entries; keep `rpc.sock` + `rest.sock` + `web_v1.sock` ### 5. Update consumers - [ ] **SDK** (`hero_aibroker_sdk`): point `openrpc_client!` at `../hero_aibroker_server/openrpc/openrpc_<domain>.json`; switch `transport.call("ai.chat", ..)` style to bare snake_case names; update doc-comment quick-starts - [ ] **Dioxus admin UI** (`hero_aibroker_app`): rewrite every `rpc::rpc("dotted.name", ...)` call site to snake_case; adjust result-shape unwraps where the macro now auto-flattens single-field `{value: ...}` outputs to bare lists / scalars - [ ] **Askama admin proxy** (`hero_aibroker_admin`): rewrite `route_socket()` → `route_domain()`; forward every `/rpc` request to the consolidated `rpc.sock` at `/api/{domain}/rpc` instead of the per-domain legacy sockets - [ ] **Test crate** (`hero_aibroker_test`): retarget every test from per-domain sockets to the consolidated socket; update JSON-RPC bodies (snake_case method names, `{"<param>": {..}}` wrapping for struct inputs) and result-shape assertions for auto-flattened returns ### 6. Cut over and delete legacy - [ ] Verify all consumers pass against the consolidated socket only - [ ] Extract `AppState` + `build_app_state` out of `src/api_openrpc/mod.rs` into a standalone `src/app_state.rs` - [ ] Relocate `src/api_openrpc/chat/translators.rs` to `src/api_openrouter/translators.rs` (only the REST socket still consumes it) - [ ] Delete `src/api_openrpc/` (entire directory) - [ ] Delete `src/rpc.rs` (legacy `RpcError` / `JsonRpcRequest` / `JsonRpcResponse` — replaced by `herolib_oschema_server::RpcError`) - [ ] Delete `crates/hero_aibroker_server/specs/` (10 hand-maintained spec files — canonical specs now in `openrpc/`) - [ ] Remove `DOMAIN_SOCKETS` const, per-domain socket helpers, the 10 accept loops + `domain_supervisor` from `main.rs` - [ ] Update doc comments at the top of `main.rs` to describe the new 3-socket reality ## Wire-name rename map Every dotted method name becomes bare snake_case under its domain: | Legacy | New | |---|---| | `ai.chat` / `.completions` / `.messages` / `.responses` / `.stream.cancel` | `chat` / `completions` / `messages` / `responses` / `stream_cancel` | | `ai.tts` / `.transcribe` / `.transcribe_verbose` | `tts` / `transcribe` / `transcribe_verbose` | | `ai.embed` / `.rerank` | `embed` / `rerank` | | `ai.image` | `image` | | `ai.video.create` / `.get` / `.list_models` | `video_create` / `video_get` / `video_list_models` | | `memory.put`/`.get`/`.list`/`.delete`/`.search` | `memory_put`/`memory_get`/`memory_list`/`memory_delete`/`memory_search` | | `memory.file.put`/`.get`/`.list`/`.delete` | `file_put`/`file_get`/`file_list`/`file_delete` | | `embedder.get`/`.set`/`.reload`/`.ping` | `embedder_get`/`embedder_set`/`embedder_reload`/`embedder_ping` | | `apikeys.create` / `.disable` / `.list` | `api_key_create` / `api_key_disable` / `api_key_list_full` (generated CRUD) | | `mothers.add`/`.list`/`.remove`/`.update` | REMOVED — generated `mother_set` (empty sid creates) / `mother_list_full` / `mother_delete` / `mother_set` | | `mcp.*` / `providers.*` / `priority.*` | `mcp_*` / `provider_*` / `priority_*` (+ new `priority_clear`) | | `logs.*` / `metrics.*` / `activity.list` / `config.get` | `logs_*` / `metrics_*` / `activity_list` / `config_get` | | `models.add/update/delete/save/catalog.refresh` | `model_add/update/delete/save` + `models_catalog_refresh` (admin domain) | | `models.list/get/config/count/key/credits/...` | drop the prefix: `list`, `get`, `config`, `count`, `key`, `credits`, … (models domain) | | `meta.info` / `.sockets` / `.health` | `info` / `sockets` / `health_detailed` | | `billing.unbilled` / `.mark_billed` | `unbilled` / `mark_billed` | | `health` / `rpc.discover` | REMOVED — now macro-provided control plane in every domain | ## Wire-shape changes - The macro auto-flattens single-field `{value: ...}` outputs: `billing.unbilled` `{"data": [...]}` becomes bare `[...]`; `billing.mark_billed` `{"marked": 0}` becomes bare `0`. Multi-field returns (e.g. `meta.info`'s `{version, uptime_seconds, is_mother, mother_count}`) are unchanged. - Inputs that take a single struct parameter are wrapped: `chat(request: ChatRequest)` is called with `{"request": {...}}`, not `{...}` flat. The SDK regen handles this for in-repo callers. ## Acceptance criteria - [ ] `cargo build --bin hero_aibroker_server` clean, zero warnings - [ ] `--info --json` reports 3 sockets (`rpc.sock`, `rest.sock`, `web_v1.sock`) - [ ] `ls ~/hero/var/sockets/hero_aibroker/` shows exactly those 3 sockets — no per-domain subdirs - [ ] `cargo test -p hero_aibroker_test` passes end-to-end against the consolidated socket - [ ] SSE smoke test: streaming chat request returns a handle + events arrive at `/api/chat/events` - [ ] Admin UI works against the consolidated socket only - [ ] Workspace `cargo build` clean across SDK, admin UI, test crate - [ ] No references to `api_openrpc::` module path (only stale doc comments allowed and to be scrubbed in a follow-up) ## Out of scope - REST `/api/v1/*` socket (`rest.sock`) — stays hand-rolled axum - Real implementations for `memory` and `video` domains — both legacy and new return `method_not_implemented`; backend work is a separate ticket
rawdaGastan added this to the ACTIVE project 2026-06-24 07:42:13 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_aibroker#165
No description provided.