diff --git a/sdk/runanywhere-cli/CMakeLists.txt b/sdk/runanywhere-cli/CMakeLists.txt index 4249f84755..7cd4b7ae7b 100644 --- a/sdk/runanywhere-cli/CMakeLists.txt +++ b/sdk/runanywhere-cli/CMakeLists.txt @@ -38,6 +38,7 @@ set(RCLI_SOURCES src/catalog/model_ref.cpp src/commands/cmd_version.cpp src/commands/cmd_info.cpp + src/commands/cmd_auth.cpp src/commands/cmd_backends.cpp src/commands/cmd_list.cpp src/commands/cmd_lora.cpp @@ -49,12 +50,14 @@ set(RCLI_SOURCES src/commands/cmd_show.cpp src/commands/cmd_stt.cpp src/commands/cmd_embed.cpp + src/commands/cmd_telemetry.cpp src/commands/cmd_tts.cpp src/commands/cmd_vad.cpp src/commands/cmd_voice.cpp src/commands/engine_options.cpp src/commands/model_setup.cpp src/config/cli_paths.cpp + src/net/control_plane.cpp src/io/wav_io.cpp src/io/image_io.cpp src/io/output.cpp diff --git a/sdk/runanywhere-cli/README.md b/sdk/runanywhere-cli/README.md index 5309e892f9..90d8993493 100644 --- a/sdk/runanywhere-cli/README.md +++ b/sdk/runanywhere-cli/README.md @@ -78,11 +78,57 @@ clear unsupported-backend error. | `rcli serve [model]` | OpenAI-compatible HTTP server (`/v1/chat/completions`, `/v1/models`, `/health`). LLM-only, one model per process | | `rcli backends` | Registered inference backends per primitive | | `rcli info` / `rcli version` | Environment / versions | +| `rcli auth login` | Real control-plane handshake: API key → JWT, device registration, model-assignment fetch | +| `rcli telemetry emit --modality ` | Emit model-free telemetry events of one modality through the real pipeline | +| `rcli telemetry blast` | Emit events of all 12 modalities in one run and print a per-modality result table | Global flags: `--json` (one machine-readable document on stdout), -`--home `, `-v/--verbose`, `-q/--quiet`, `--no-progress`. +`--home `, `-v/--verbose`, `-q/--quiet`, `--no-progress`, plus the +control-plane connection flags below. Exit codes: `0` ok · `1` runtime error · `2` usage error · `130` cancelled. +## Control plane + +rcli can drive any RunAnywhere control plane — including a local backend on +`http://localhost` — with three global flags (each with an env-var fallback): + +| Flag | Env var | Meaning | +|---|---|---| +| `--environment ` | `RUNANYWHERE_ENV` | `dev` (default) is offline — no control plane. `staging` allows `http://` and localhost URLs. `prod` requires `https://` and rejects localhost | +| `--base-url ` | `RUNANYWHERE_BASE_URL` | Backend origin, e.g. `https://api.runanywhere.ai` or `http://127.0.0.1:8000` | +| `--api-key ` | `RUNANYWHERE_API_KEY` | Control-plane API key (≥ 10 chars), required for staging/prod | + +Combos are validated client-side before any network call: staging/prod +require both a key and a URL; passing credentials while in dev mode is an +error. With no flags at all, every command behaves exactly as before +(offline development mode). + +```console +$ rcli --environment staging --base-url http://127.0.0.1:8000 --api-key $KEY auth login +organization 293beb67-… +device e87d77a2-… +token expires 2026-07-19T08:44:31Z +device row registered +assignments 0 model(s) + +$ rcli --environment staging --base-url http://127.0.0.1:8000 --api-key $KEY telemetry blast +MODALITY RESULT STATUS RECEIVED STORED SKIPPED +llm ok HTTP 200 1 1 0 +… (one row per modality, 12 total) +``` + +- `auth login` runs the same handshake the mobile SDKs run + (`/api/v1/auth/sdk/authenticate` → `/api/v1/devices/register` → + model assignments) and exits non-zero with the server's error surfaced when + anything fails. +- `telemetry emit|blast` drive the real commons telemetry pipeline: payloads + are batched per modality and POSTed to `/api/v2/sdk/telemetry/{modality}` + with the JWT from the login handshake. The V2 endpoints require a JWT, so + both commands log in first — one process performs login + emit (the token + is held in-process, not persisted). Modalities: `llm stt tts vlm rag + imagegen embeddings vad voice lora model system`. Exit is non-zero when any + POST fails or any tracked event never reached the backend. + ### `rcli run` REPL Launched when you give no prompt and stdin is a TTY. Line editing + history diff --git a/sdk/runanywhere-cli/src/app.cpp b/sdk/runanywhere-cli/src/app.cpp index f01ea67a26..b9c6cde505 100644 --- a/sdk/runanywhere-cli/src/app.cpp +++ b/sdk/runanywhere-cli/src/app.cpp @@ -28,6 +28,20 @@ void configure_app(CLI::App& app, GlobalOptions& options) { "RunAnywhere home directory (default: $RUNANYWHERE_HOME or " "~/.local/share/runanywhere; models live under /Models)"); + // Control-plane connection. Absent flags keep the historical offline + // development-mode defaults; validation happens in resolve_connection(). + app.add_option("--environment", options.environment, + "Control-plane environment: dev (default, offline), staging " + "(http + localhost allowed) or prod (https only)") + ->envname("RUNANYWHERE_ENV") + ->check(CLI::IsMember({"dev", "development", "staging", "prod", "production"})); + app.add_option("--base-url", options.base_url, + "Control-plane base URL, e.g. https://api.runanywhere.ai or " + "http://localhost:8000 (staging/prod)") + ->envname("RUNANYWHERE_BASE_URL"); + app.add_option("--api-key", options.api_key, "Control-plane API key (staging/prod)") + ->envname("RUNANYWHERE_API_KEY"); + commands::register_version(app, options); commands::register_info(app, options); commands::register_backends(app, options); @@ -44,6 +58,8 @@ void configure_app(CLI::App& app, GlobalOptions& options) { commands::register_vad(app, options); commands::register_voice(app, options); commands::register_serve(app, options); + commands::register_auth(app, options); + commands::register_telemetry(app, options); } int run(int argc, char** argv) { diff --git a/sdk/runanywhere-cli/src/bootstrap.cpp b/sdk/runanywhere-cli/src/bootstrap.cpp index 3edbfb1221..46eac3a579 100644 --- a/sdk/runanywhere-cli/src/bootstrap.cpp +++ b/sdk/runanywhere-cli/src/bootstrap.cpp @@ -10,6 +10,7 @@ #include "rac/core/rac_core.h" #include "rac/core/rac_logger.h" #include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_sdk_state.h" #include "rac/desktop/rac_desktop.h" #include "rac/infrastructure/device/rac_device_identity.h" #include "rac/infrastructure/model_management/rac_model_paths.h" @@ -18,6 +19,7 @@ #include "catalog/catalog.h" #include "config/cli_paths.h" #include "io/output.h" +#include "net/control_plane.h" #if defined(RCLI_HAS_LLAMACPP) #include "rac/backends/rac_llm_llamacpp.h" @@ -150,7 +152,7 @@ const char *desktop_platform() { #endif } -void initialize_sdk_metadata() { +void initialize_sdk_metadata(const Connection &connection) { char device_id[RAC_DEVICE_ID_BUFFER_MIN_SIZE] = {}; const rac_result_t device_rc = rac_device_get_or_create_persistent_id(device_id, sizeof(device_id)); @@ -163,10 +165,21 @@ void initialize_sdk_metadata() { const std::string locale = detect_locale(); const std::string timezone = detect_timezone(); + // Mirror rac_sdk_init_phase1_proto's step order: runtime state first (the + // auth / device-registration / telemetry paths read env + credentials from + // rac_state), then the copied SDK configuration + client info. + const rac_result_t state_rc = rac_state_initialize( + connection.environment, connection.api_key.c_str(), + connection.base_url.c_str(), device_id[0] != '\0' ? device_id : ""); + if (state_rc != RAC_SUCCESS) { + out::status_line("warning: SDK state init failed: " + + out::describe_result(state_rc)); + } + rac_sdk_config_t sdk_config = {}; - sdk_config.environment = RAC_ENV_DEVELOPMENT; - sdk_config.api_key = ""; - sdk_config.base_url = ""; + sdk_config.environment = connection.environment; + sdk_config.api_key = connection.api_key.c_str(); + sdk_config.base_url = connection.base_url.c_str(); sdk_config.device_id = device_id[0] != '\0' ? device_id : ""; sdk_config.platform = desktop_platform(); sdk_config.sdk_version = RCLI_VERSION; @@ -185,8 +198,79 @@ void initialize_sdk_metadata() { } } +bool parse_environment_name(const std::string &name, rac_environment_t *out) { + if (name.empty() || name == "dev" || name == "development") { + *out = RAC_ENV_DEVELOPMENT; + return true; + } + if (name == "staging") { + *out = RAC_ENV_STAGING; + return true; + } + if (name == "prod" || name == "production") { + *out = RAC_ENV_PRODUCTION; + return true; + } + return false; +} + } // namespace +rac_result_t resolve_connection(const GlobalOptions &options, Connection *out, + std::string *error) { + Connection connection; + if (!parse_environment_name(options.environment, &connection.environment)) { + if (error) { + *error = "invalid --environment '" + options.environment + + "' (expected dev, staging or prod)"; + } + return RAC_ERROR_INVALID_CONFIGURATION; + } + connection.base_url = options.base_url; + connection.api_key = options.api_key; + + if (connection.environment == RAC_ENV_DEVELOPMENT) { + if (!connection.api_key.empty() || !connection.base_url.empty()) { + if (error) { + *error = "development mode (the default) has no control plane; pass " + "--environment staging (or prod) together with --base-url " + "and --api-key"; + } + return RAC_ERROR_INVALID_CONFIGURATION; + } + if (out) { + *out = connection; + } + return RAC_SUCCESS; + } + + const rac_validation_result_t key_rc = rac_validate_api_key( + connection.api_key.empty() ? nullptr : connection.api_key.c_str(), + connection.environment); + if (key_rc != RAC_VALIDATION_OK) { + if (error) { + *error = std::string(rac_validation_error_message(key_rc)) + + " (--api-key / RUNANYWHERE_API_KEY)"; + } + return RAC_ERROR_INVALID_CONFIGURATION; + } + const rac_validation_result_t url_rc = rac_validate_base_url( + connection.base_url.empty() ? nullptr : connection.base_url.c_str(), + connection.environment); + if (url_rc != RAC_VALIDATION_OK) { + if (error) { + *error = std::string(rac_validation_error_message(url_rc)) + + " (--base-url / RUNANYWHERE_BASE_URL)"; + } + return RAC_ERROR_INVALID_CONFIGURATION; + } + + if (out) { + *out = connection; + } + return RAC_SUCCESS; +} + rac_result_t bootstrap(const GlobalOptions &options, Bootstrapped *out) { const std::string home = paths::resolve_home(options.home_override); if (home.empty()) { @@ -194,6 +278,14 @@ rac_result_t bootstrap(const GlobalOptions &options, Bootstrapped *out) { return RAC_ERROR_NOT_INITIALIZED; } + Connection connection; + std::string connection_error; + if (resolve_connection(options, &connection, &connection_error) != + RAC_SUCCESS) { + out::error_line(connection_error); + return RAC_ERROR_INVALID_CONFIGURATION; + } + if (!g_bootstrapped) { rac_result_t rc = rac_desktop_adapter_init(nullptr, &g_adapter); if (rc != RAC_SUCCESS) { @@ -234,7 +326,13 @@ rac_result_t bootstrap(const GlobalOptions &options, Bootstrapped *out) { return rc; } - initialize_sdk_metadata(); + initialize_sdk_metadata(connection); + + // Platform wiring for the control-plane flows (`rcli auth login`, + // `rcli telemetry ...`): device-manager callbacks route registration + // POSTs through the registered curl transport. Same role the per-SDK + // bridges play; a no-op for commands that never touch the network. + net::register_device_callbacks(); #if defined(RCLI_HAS_LLAMACPP) if (rac_backend_llamacpp_register() != RAC_SUCCESS) { diff --git a/sdk/runanywhere-cli/src/bootstrap.h b/sdk/runanywhere-cli/src/bootstrap.h index 7842cfa03d..3d516f172b 100644 --- a/sdk/runanywhere-cli/src/bootstrap.h +++ b/sdk/runanywhere-cli/src/bootstrap.h @@ -17,6 +17,7 @@ #include #include "rac/core/rac_types.h" +#include "rac/infrastructure/network/rac_environment.h" namespace rcli { @@ -27,8 +28,40 @@ struct GlobalOptions { bool quiet = false; bool no_progress = false; std::string home_override; // --home flag + + // Control-plane connection. Empty defaults preserve the historical + // offline development-mode behavior exactly. CLI11 fills these from + // --base-url/--api-key/--environment with RUNANYWHERE_BASE_URL / + // RUNANYWHERE_API_KEY / RUNANYWHERE_ENV env-var fallbacks (app.cpp). + std::string environment; // dev|development|staging|prod|production ("" → dev) + std::string base_url; // required for staging/prod (http allowed on staging) + std::string api_key; // required for staging/prod (≥10 chars) +}; + +/** + * Validated control-plane connection resolved from GlobalOptions. + * bootstrap() threads these values into rac_state / rac_sdk_config so the + * commons auth, device-registration, and telemetry paths can read them. + */ +struct Connection { + rac_environment_t environment = RAC_ENV_DEVELOPMENT; + std::string base_url; + std::string api_key; }; +/** + * Resolve + validate the connection flags client-side (before any network + * call). On failure fills `error` with an actionable message and returns + * RAC_ERROR_INVALID_CONFIGURATION. + * + * Rules (mirrors commons rac_validate_api_key / rac_validate_base_url): + * - dev (default): no credentials allowed — pass --environment staging to + * target a real control plane (localhost is allowed on staging). + * - staging: api key (≥10 chars) + http(s) base URL required. + * - prod: api key + https base URL required; localhost rejected. + */ +rac_result_t resolve_connection(const GlobalOptions& options, Connection* out, std::string* error); + /** Resolved environment after bootstrap. */ struct Bootstrapped { std::string home; // RunAnywhere home (storage base dir) diff --git a/sdk/runanywhere-cli/src/commands/cmd_auth.cpp b/sdk/runanywhere-cli/src/commands/cmd_auth.cpp new file mode 100644 index 0000000000..33e7bd1a2b --- /dev/null +++ b/sdk/runanywhere-cli/src/commands/cmd_auth.cpp @@ -0,0 +1,122 @@ +/** + * @file cmd_auth.cpp + * @brief `rcli auth login` — real control-plane handshake. + * + * Runs the canonical staging/production auth sequence against the configured + * backend (--base-url/--api-key/--environment or their RUNANYWHERE_* env + * vars): authenticate (API key → JWT + refresh token), device registration, + * and model-assignment fetch — all through commons entry points + * (net::login → rac_auth_* + rac_sdk_init_phase2_proto). + */ + +#include "commands/commands.h" + +#include +#include +#include + +#include "net/control_plane.h" + +#include "io/output.h" + +namespace rcli::commands { + +namespace { + +std::string format_epoch_seconds(int64_t seconds) { + if (seconds <= 0) { + return "-"; + } + const time_t secs = static_cast(seconds); + struct tm tm_info{}; +#if defined(_WIN32) + if (gmtime_s(&tm_info, &secs) != 0) { + return std::to_string(seconds); + } +#else + if (gmtime_r(&secs, &tm_info) == nullptr) { + return std::to_string(seconds); + } +#endif + char buffer[32] = {}; + strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm_info); + return buffer; +} + +int run_auth_login(const GlobalOptions& options) { + Bootstrapped env; + if (bootstrap(options, &env) != RAC_SUCCESS) { + return 1; + } + + net::LoginSummary summary; + std::string error; + if (net::login(&summary, &error) != RAC_SUCCESS) { + out::error_line(error); + return 1; + } + + // A staging/production login without a device row is a broken control + // plane — surface it as a failure, not a footnote. + const bool ok = summary.device_registered; + + if (options.json) { + out::JsonWriter json; + json.begin_object() + .field("success", ok) + .field("organization_id", summary.organization_id) + .field("user_id", summary.user_id) + .field("device_id", summary.backend_device_id) + .field("device_uuid", summary.persistent_device_id) + .field("token_expires_at", format_epoch_seconds(summary.token_expires_at)) + .field("device_registered", summary.device_registered) + .field("assignments", static_cast(summary.assignment_count)); + if (!summary.warning.empty()) { + json.field("warning", summary.warning); + } + json.end_object(); + out::result_line(json.str()); + } else { + out::result_line("organization " + summary.organization_id); + out::result_line("user " + + (summary.user_id.empty() ? std::string("-") : summary.user_id)); + out::result_line("device " + summary.backend_device_id); + out::result_line("device-uuid " + summary.persistent_device_id); + out::result_line("token expires " + format_epoch_seconds(summary.token_expires_at)); + out::result_line(std::string("device row ") + + (summary.device_registered ? "registered" : "NOT registered")); + out::result_line("assignments " + std::to_string(summary.assignment_count) + + " model(s)"); + if (!summary.warning.empty()) { + out::status_line("warning: " + summary.warning); + } + } + + if (!ok) { + out::error_line("device registration did not complete" + + (summary.warning.empty() ? "" : ": " + summary.warning)); + return 1; + } + return 0; +} + +} // namespace + +void register_auth(CLI::App& app, GlobalOptions& options) { + CLI::App* cmd = app.add_subcommand("auth", "Control-plane authentication"); + cmd->require_subcommand(1); + + CLI::App* login_cmd = cmd->add_subcommand( + "login", + "Authenticate against the configured backend (API key → JWT), register " + "this device and fetch model assignments. Requires --environment " + "staging|prod with --base-url and --api-key (or RUNANYWHERE_* env vars)."); + login_cmd->callback([&options]() { + const int exit_code = run_auth_login(options); + if (exit_code != 0) { + throw CLI::RuntimeError(exit_code); + } + }); +} + +} // namespace rcli::commands diff --git a/sdk/runanywhere-cli/src/commands/cmd_telemetry.cpp b/sdk/runanywhere-cli/src/commands/cmd_telemetry.cpp new file mode 100644 index 0000000000..48bb6b4091 --- /dev/null +++ b/sdk/runanywhere-cli/src/commands/cmd_telemetry.cpp @@ -0,0 +1,478 @@ +/** + * @file cmd_telemetry.cpp + * @brief `rcli telemetry emit|blast` — model-free control-plane telemetry. + * + * Drives the real commons telemetry pipeline end-to-end: payloads are queued + * with rac_telemetry_manager_track, batched + serialized by commons + * (one POST per modality to /api/v2/sdk/telemetry/{modality}), and delivered + * through the CLI's HTTP callback over the registered curl transport with the + * JWT from the login handshake. + * + * Staging/production only (the V2 endpoints require a JWT); both commands run + * the login handshake first, so one process does login + emit. Exits non-zero + * when any POST fails or any tracked event never reached the backend. + */ + +#include "commands/commands.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_sdk_state.h" +#include "rac/infrastructure/telemetry/rac_telemetry_manager.h" +#include "rac/infrastructure/telemetry/rac_telemetry_types.h" + +#include "io/output.h" +#include "net/control_plane.h" + +#ifndef RCLI_VERSION +#define RCLI_VERSION "0.0.0-dev" +#endif + +namespace rcli::commands { + +namespace { + +// The 12 modalities recognized by the V2 telemetry pipeline (one backend +// endpoint each), paired with a realistic terminal event type drawn from the +// canonical names the SDK emits (telemetry_manager.cpp / the backend's +// normalizer treats *.completed as terminal). +struct ModalitySpec { + const char* name; + const char* default_event_type; +}; + +constexpr ModalitySpec kModalities[] = { + {"llm", "llm.generation.completed"}, + {"stt", "stt.transcription.completed"}, + {"tts", "tts.synthesis.completed"}, + {"vlm", "vlm.process.completed"}, + {"rag", "rag.query.completed"}, + {"imagegen", "imagegen.generate.completed"}, + {"embeddings", "embeddings.embed.completed"}, + {"vad", "vad.stopped"}, + {"voice", "voice.turn.metrics"}, + {"lora", "lora.attach.completed"}, + {"model", "model.download.completed"}, + {"system", "sdk.init.completed"}, +}; + +const ModalitySpec* find_modality(const std::string& name) { + for (const ModalitySpec& spec : kModalities) { + if (name == spec.name) { + return &spec; + } + } + return nullptr; +} + +std::vector modality_names() { + std::vector names; + for (const ModalitySpec& spec : kModalities) { + names.emplace_back(spec.name); + } + return names; +} + +std::string uuid4() { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::uniform_int_distribution dist; + uint64_t hi = dist(rng); + uint64_t lo = dist(rng); + hi = (hi & 0xFFFFFFFFFFFF0FFFull) | 0x0000000000004000ull; // version 4 + lo = (lo & 0x3FFFFFFFFFFFFFFFull) | 0x8000000000000000ull; // RFC-4122 variant + char buffer[37] = {}; + std::snprintf(buffer, sizeof(buffer), "%08" PRIx64 "-%04" PRIx64 "-%04" PRIx64 "-%04" PRIx64 + "-%012" PRIx64, + hi >> 32, (hi >> 16) & 0xFFFFull, hi & 0xFFFFull, lo >> 48, + lo & 0xFFFFFFFFFFFFull); + return buffer; +} + +// Minimal field extraction from the backend's SDKTelemetryBatchResponse JSON +// ({"success":true,"events_received":N,"events_stored":N,"events_skipped":N, +// "storage_version":"V2"}). The CLI deliberately carries no JSON parser. +int extract_int_field(const std::string& json, const std::string& key) { + const std::string needle = "\"" + key + "\":"; + const size_t pos = json.find(needle); + if (pos == std::string::npos) { + return -1; + } + return std::atoi(json.c_str() + pos + needle.size()); +} + +bool extract_bool_field(const std::string& json, const std::string& key) { + const std::string needle = "\"" + key + "\":"; + const size_t pos = json.find(needle); + return pos != std::string::npos && json.compare(pos + needle.size(), 4, "true") == 0; +} + +// Per-endpoint accounting accumulated inside the telemetry HTTP callback. +struct EndpointStats { + int posts = 0; + int failures = 0; + int last_status = 0; + int received = 0; + int stored = 0; + int skipped = 0; + std::string last_error; +}; + +struct TelemetryHttpContext { + std::map endpoints; // key: endpoint path +}; + +void telemetry_http_callback(void* user_data, const char* endpoint, const char* json_body, + size_t json_length, rac_bool_t requires_auth) { + auto* context = static_cast(user_data); + if (context == nullptr || endpoint == nullptr) { + return; + } + const net::HttpResult result = net::control_plane_post( + endpoint, std::string(json_body != nullptr ? json_body : "", json_length), + requires_auth == RAC_TRUE); + + EndpointStats& stats = context->endpoints[endpoint]; + stats.posts += 1; + stats.last_status = result.status; + if (!result.ok()) { + stats.failures += 1; + stats.last_error = result.describe(); + return; + } + if (!extract_bool_field(result.body, "success")) { + stats.failures += 1; + stats.last_error = "backend reported success=false: " + result.body; + } + const int received = extract_int_field(result.body, "events_received"); + const int stored = extract_int_field(result.body, "events_stored"); + const int skipped = extract_int_field(result.body, "events_skipped"); + stats.received += received > 0 ? received : 0; + stats.stored += stored > 0 ? stored : 0; + stats.skipped += skipped > 0 ? skipped : 0; +} + +/** Optional metric flags shared by emit and blast. Negative = unset. */ +struct MetricOptions { + double processing_ms = -1.0; + int32_t input_tokens = -1; + int32_t output_tokens = -1; + double audio_duration_ms = -1.0; +}; + +void track_events(rac_telemetry_manager_t* manager, const ModalitySpec& spec, + const std::string& event_type, const std::string& session_id, int count, + const MetricOptions& metrics) { + for (int i = 0; i < count; ++i) { + const std::string event_id = uuid4(); + rac_telemetry_payload_t payload = rac_telemetry_payload_default(); + payload.id = event_id.c_str(); + payload.event_type = event_type.c_str(); + payload.modality = spec.name; + payload.session_id = session_id.c_str(); + const int64_t now_ms = rac_get_current_time_ms(); + payload.timestamp_ms = now_ms; + payload.created_at_ms = now_ms; + payload.success = RAC_TRUE; + payload.has_success = RAC_TRUE; + if (metrics.processing_ms >= 0) { + payload.processing_time_ms = metrics.processing_ms; + payload.has_processing_time_ms = RAC_TRUE; + } + if (metrics.input_tokens >= 0) { + payload.input_tokens = metrics.input_tokens; + } + if (metrics.output_tokens >= 0) { + payload.output_tokens = metrics.output_tokens; + payload.total_tokens = (metrics.input_tokens > 0 ? metrics.input_tokens : 0) + + metrics.output_tokens; + } + if (metrics.audio_duration_ms >= 0) { + payload.audio_duration_ms = metrics.audio_duration_ms; + } + rac_telemetry_manager_track(manager, &payload); + } +} + +struct FlushReport { + TelemetryHttpContext context; + int tracked = 0; +}; + +/** + * Login (JWT), create a manager wired to the real transport, run `track_fn`, + * flush, and account per-endpoint results. Returns false on login failure. + */ +template +bool run_telemetry_session(const GlobalOptions& options, FlushReport* report, TrackFn&& track_fn) { + Bootstrapped env; + if (bootstrap(options, &env) != RAC_SUCCESS) { + return false; + } + + // The V2 telemetry endpoints only accept a JWT, so emit implies login — + // one process performs the handshake and the flush (in-process token). + std::string error; + if (net::login(nullptr, &error) != RAC_SUCCESS) { + out::error_line(error); + return false; + } + + const char* device_id = rac_state_get_device_id(); + rac_telemetry_manager_t* manager = rac_telemetry_manager_create( + rac_state_get_environment(), device_id != nullptr ? device_id : "", net::platform_name(), + RCLI_VERSION); + if (manager == nullptr) { + out::error_line("telemetry manager creation failed"); + return false; + } + rac_telemetry_manager_set_device_info(manager, net::device_model().c_str(), + net::os_version_string().c_str()); + rac_telemetry_manager_set_http_callback(manager, telemetry_http_callback, &report->context); + + report->tracked = track_fn(manager); + rac_telemetry_manager_flush(manager); + rac_telemetry_manager_set_http_callback(manager, nullptr, nullptr); + rac_telemetry_manager_destroy(manager); + return true; +} + +int total_received(const FlushReport& report) { + int received = 0; + for (const auto& [endpoint, stats] : report.context.endpoints) { + received += stats.received; + } + return received; +} + +bool report_failed(const FlushReport& report) { + if (report.context.endpoints.empty()) { + return true; // nothing was POSTed — flush deferred or dropped + } + for (const auto& [endpoint, stats] : report.context.endpoints) { + if (stats.failures > 0) { + return true; + } + } + return total_received(report) != report.tracked; +} + +void render_endpoint_results(const GlobalOptions& options, const FlushReport& report) { + if (options.json) { + out::JsonWriter json; + json.begin_object() + .field("tracked", static_cast(report.tracked)) + .field("success", !report_failed(report)) + .begin_array("endpoints"); + for (const auto& [endpoint, stats] : report.context.endpoints) { + json.begin_array_object() + .field("endpoint", endpoint) + .field("posts", static_cast(stats.posts)) + .field("http_status", static_cast(stats.last_status)) + .field("events_received", static_cast(stats.received)) + .field("events_stored", static_cast(stats.stored)) + .field("events_skipped", static_cast(stats.skipped)); + if (!stats.last_error.empty()) { + json.field("error", stats.last_error); + } + json.end_object(); + } + json.end_array().end_object(); + out::result_line(json.str()); + return; + } + + if (report.context.endpoints.empty()) { + out::error_line("no telemetry batch was sent (flush deferred?)"); + return; + } + for (const auto& [endpoint, stats] : report.context.endpoints) { + std::string line = endpoint + " HTTP " + std::to_string(stats.last_status) + + " received=" + std::to_string(stats.received) + + " stored=" + std::to_string(stats.stored) + + " skipped=" + std::to_string(stats.skipped); + if (!stats.last_error.empty()) { + line += " error: " + stats.last_error; + } + out::result_line(line); + } +} + +int run_telemetry_emit(const GlobalOptions& options, const std::string& modality, + const std::string& event_type, int count, const std::string& session_id, + const MetricOptions& metrics) { + const ModalitySpec* spec = find_modality(modality); + if (spec == nullptr) { + out::error_line("unknown modality '" + modality + "'"); + return 2; + } + const std::string resolved_event_type = + event_type.empty() ? spec->default_event_type : event_type; + const std::string resolved_session = session_id.empty() ? uuid4() : session_id; + + FlushReport report; + const bool session_ok = run_telemetry_session( + options, &report, [&](rac_telemetry_manager_t* manager) { + track_events(manager, *spec, resolved_event_type, resolved_session, count, metrics); + return count; + }); + if (!session_ok) { + return 1; + } + + if (!options.json) { + out::status_line("emitted " + std::to_string(count) + " × " + resolved_event_type + + " (modality " + modality + ", session " + resolved_session + ")"); + } + render_endpoint_results(options, report); + return report_failed(report) ? 1 : 0; +} + +int run_telemetry_blast(const GlobalOptions& options, int count, const std::string& session_id, + const MetricOptions& metrics) { + const std::string resolved_session = session_id.empty() ? uuid4() : session_id; + + FlushReport report; + const bool session_ok = run_telemetry_session( + options, &report, [&](rac_telemetry_manager_t* manager) { + for (const ModalitySpec& spec : kModalities) { + track_events(manager, spec, spec.default_event_type, resolved_session, count, + metrics); + } + return count * static_cast(std::size(kModalities)); + }); + if (!session_ok) { + return 1; + } + + bool all_ok = true; + std::vector> rows; + for (const ModalitySpec& spec : kModalities) { + const std::string endpoint = std::string("/api/v2/sdk/telemetry/") + spec.name; + const auto it = report.context.endpoints.find(endpoint); + std::string status = "NO POST"; + int received = 0; + int stored = 0; + int skipped = 0; + bool row_ok = false; + if (it != report.context.endpoints.end()) { + const EndpointStats& stats = it->second; + received = stats.received; + stored = stats.stored; + skipped = stats.skipped; + row_ok = stats.failures == 0 && stats.received == count; + status = row_ok ? ("HTTP " + std::to_string(stats.last_status)) + : (stats.last_error.empty() + ? "HTTP " + std::to_string(stats.last_status) + : stats.last_error); + } + all_ok = all_ok && row_ok; + rows.push_back({spec.name, row_ok ? "ok" : "FAILED", status, std::to_string(received), + std::to_string(stored), std::to_string(skipped)}); + } + + if (options.json) { + out::JsonWriter json; + json.begin_object() + .field("tracked", static_cast(report.tracked)) + .field("success", all_ok) + .field("session_id", resolved_session) + .begin_array("modalities"); + for (const auto& row : rows) { + json.begin_array_object() + .field("modality", row[0]) + .field("ok", row[1] == "ok") + .field("status", row[2]) + .field("events_received", static_cast(std::atoi(row[3].c_str()))) + .field("events_stored", static_cast(std::atoi(row[4].c_str()))) + .field("events_skipped", static_cast(std::atoi(row[5].c_str()))) + .end_object(); + } + json.end_array().end_object(); + out::result_line(json.str()); + } else { + out::status_line("blast session " + resolved_session + " — " + + std::to_string(report.tracked) + " event(s) across " + + std::to_string(std::size(kModalities)) + " modalities"); + out::table({"MODALITY", "RESULT", "STATUS", "RECEIVED", "STORED", "SKIPPED"}, rows); + } + return all_ok ? 0 : 1; +} + +} // namespace + +void register_telemetry(CLI::App& app, GlobalOptions& options) { + CLI::App* cmd = app.add_subcommand( + "telemetry", "Emit model-free telemetry through the real control-plane pipeline"); + cmd->require_subcommand(1); + + // ---- telemetry emit ---------------------------------------------------- + CLI::App* emit_cmd = cmd->add_subcommand( + "emit", + "Track N events of one modality, flush to /api/v2/sdk/telemetry/{modality} " + "and report the backend's accounting. Runs the auth handshake first " + "(staging/prod only). Exits non-zero when any POST fails."); + auto modality = std::make_shared(); + auto event_type = std::make_shared(); + auto count = std::make_shared(1); + auto session_id = std::make_shared(); + auto metrics = std::make_shared(); + emit_cmd->add_option("--modality", *modality, "Telemetry modality") + ->required() + ->check(CLI::IsMember(modality_names())); + emit_cmd->add_option("--event-type", *event_type, + "Event type string (default: the modality's terminal event, e.g. " + "llm.generation.completed)"); + emit_cmd->add_option("--count", *count, "Number of events to emit (default 1)") + ->check(CLI::PositiveNumber); + emit_cmd->add_option("--session-id", *session_id, + "Session id attached to every event (default: fresh UUID)"); + emit_cmd->add_option("--processing-ms", metrics->processing_ms, + "processing_time_ms metric for every event"); + emit_cmd->add_option("--input-tokens", metrics->input_tokens, + "input_tokens metric (llm/vlm modalities)"); + emit_cmd->add_option("--output-tokens", metrics->output_tokens, + "output_tokens metric (llm/vlm modalities)"); + emit_cmd->add_option("--audio-duration-ms", metrics->audio_duration_ms, + "audio_duration_ms metric (stt modality)"); + emit_cmd->callback([&options, modality, event_type, count, session_id, metrics]() { + const int exit_code = run_telemetry_emit(options, *modality, *event_type, *count, + *session_id, *metrics); + if (exit_code != 0) { + throw CLI::RuntimeError(exit_code); + } + }); + + // ---- telemetry blast --------------------------------------------------- + CLI::App* blast_cmd = cmd->add_subcommand( + "blast", + "Emit --count events of EVERY modality (all 12) in one run, flush, and " + "print a per-modality result table parsed from the backend's batch " + "responses. The integration-suite workhorse."); + auto blast_count = std::make_shared(1); + auto blast_session = std::make_shared(); + auto blast_metrics = std::make_shared(); + blast_cmd->add_option("--count", *blast_count, "Events per modality (default 1)") + ->check(CLI::PositiveNumber); + blast_cmd->add_option("--session-id", *blast_session, + "Session id attached to every event (default: fresh UUID)"); + blast_cmd->add_option("--processing-ms", blast_metrics->processing_ms, + "processing_time_ms metric for every event"); + blast_cmd->callback([&options, blast_count, blast_session, blast_metrics]() { + const int exit_code = + run_telemetry_blast(options, *blast_count, *blast_session, *blast_metrics); + if (exit_code != 0) { + throw CLI::RuntimeError(exit_code); + } + }); +} + +} // namespace rcli::commands diff --git a/sdk/runanywhere-cli/src/commands/commands.h b/sdk/runanywhere-cli/src/commands/commands.h index 017c0c908f..b4ba3fa393 100644 --- a/sdk/runanywhere-cli/src/commands/commands.h +++ b/sdk/runanywhere-cli/src/commands/commands.h @@ -36,6 +36,8 @@ void register_vad(CLI::App& app, GlobalOptions& options); void register_voice(CLI::App& app, GlobalOptions& options); void register_serve(CLI::App& app, GlobalOptions& options); void register_lora(CLI::App& app, GlobalOptions& options); +void register_auth(CLI::App& app, GlobalOptions& options); +void register_telemetry(CLI::App& app, GlobalOptions& options); /** * Shared pull flow (plan → start → progress → terminal state) for an diff --git a/sdk/runanywhere-cli/src/net/control_plane.cpp b/sdk/runanywhere-cli/src/net/control_plane.cpp new file mode 100644 index 0000000000..d74f0d410f --- /dev/null +++ b/sdk/runanywhere-cli/src/net/control_plane.cpp @@ -0,0 +1,446 @@ +/** + * @file control_plane.cpp + * @brief Control-plane network wiring for rcli — see control_plane.h. + * + * The CLI supplies platform callbacks (device info + HTTP via the registered + * curl transport) and drives the canonical commons entry points. Request + * building (rac_auth_build_authenticate_request, device registration JSON) + * and response parsing (rac_auth_handle_authenticate_response, + * SdkInitResult) stay in commons per the repo layering rule. + */ + +#include "net/control_plane.h" + +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#endif +#if !defined(_WIN32) +#include +#include +#endif + +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_sdk_state.h" +#include "rac/infrastructure/device/rac_device_manager.h" +#include "rac/infrastructure/http/rac_http_client.h" +#include "rac/infrastructure/network/rac_auth_manager.h" +#include "rac/infrastructure/network/rac_endpoints.h" +#include "rac/infrastructure/network/rac_environment.h" +#include "rac/lifecycle/rac_sdk_init.h" + +#include "sdk_init.pb.h" + +#include "io/output.h" +#include "io/proto.h" + +namespace rcli::net { + +namespace { + +namespace v1 = runanywhere::v1; + +constexpr size_t kErrorBodyPreview = 500; + +std::string single_line_preview(const std::string& body) { + std::string preview = body.substr(0, kErrorBodyPreview); + for (char& ch : preview) { + if (ch == '\n' || ch == '\r' || ch == '\t') { + ch = ' '; + } + } + if (body.size() > kErrorBodyPreview) { + preview += "…"; + } + return preview; +} + +std::string query_hostname() { +#if defined(_WIN32) + const char* name = std::getenv("COMPUTERNAME"); + return name != nullptr ? name : "windows-host"; +#else + struct utsname info{}; + if (uname(&info) == 0 && info.nodename[0] != '\0') { + return info.nodename; + } + return "desktop-host"; +#endif +} + +std::string query_device_model() { +#if defined(__APPLE__) + char model[128] = {}; + size_t size = sizeof(model); + if (sysctlbyname("hw.model", model, &size, nullptr, 0) == 0 && model[0] != '\0') { + return model; + } + return "Mac"; +#elif defined(_WIN32) + return "Windows PC"; +#else + struct utsname info{}; + if (uname(&info) == 0 && info.machine[0] != '\0') { + return std::string(info.sysname[0] != '\0' ? info.sysname : "Linux") + " " + info.machine; + } + return "Linux PC"; +#endif +} + +std::string query_os_version() { +#if defined(_WIN32) + return {}; +#else + struct utsname info{}; + if (uname(&info) == 0 && info.release[0] != '\0') { + // Backend os_version column caps at 20 chars. + return std::string(info.release).substr(0, 20); + } + return {}; +#endif +} + +std::string query_chip_name() { +#if defined(__APPLE__) + char brand[256] = {}; + size_t size = sizeof(brand); + if (sysctlbyname("machdep.cpu.brand_string", brand, &size, nullptr, 0) == 0 && + brand[0] != '\0') { + return brand; + } +#endif + return {}; +} + +const char* architecture_name() { +#if defined(__aarch64__) || defined(_M_ARM64) + return "arm64"; +#else + return "x86_64"; +#endif +} + +// --------------------------------------------------------------------------- +// Device-manager callbacks. The device manager reads the strings we hand it +// after the callback returns (it builds the registration JSON immediately), +// so all backing storage is file-static — the CLI drives one control-plane +// flow at a time. +// --------------------------------------------------------------------------- + +struct DeviceBridgeState { + bool registered_this_process = false; + std::string device_id; // rac_state persistent UUID snapshot + std::string device_name; // hostname + std::string response_body; // outlives the http_post callback + std::string response_error; // outlives the http_post callback +}; + +DeviceBridgeState& device_state() { + static DeviceBridgeState state; + return state; +} + +void device_get_info(rac_device_registration_info_t* out_info, void* /*user_data*/) { + if (out_info == nullptr) { + return; + } + DeviceBridgeState& state = device_state(); + state.device_name = query_hostname(); + + *out_info = {}; + out_info->device_model = device_model().c_str(); + out_info->device_name = state.device_name.c_str(); + out_info->platform = platform_name(); + out_info->os_version = os_version_string().c_str(); + out_info->form_factor = "desktop"; + out_info->architecture = architecture_name(); + static const std::string chip = query_chip_name(); + out_info->chip_name = chip.c_str(); + + rac_memory_info_t memory{}; + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + if (adapter != nullptr && adapter->get_memory_info != nullptr && + adapter->get_memory_info(&memory, adapter->user_data) == RAC_SUCCESS) { + out_info->total_memory = static_cast(memory.total_bytes); + out_info->available_memory = static_cast(memory.available_bytes); + } + + out_info->has_neural_engine = RAC_FALSE; + out_info->neural_engine_cores = 0; +#if defined(__APPLE__) + out_info->gpu_family = "apple"; +#else + out_info->gpu_family = nullptr; +#endif + out_info->battery_level = -1.0; // desktop: unavailable → null on the wire + out_info->battery_state = nullptr; + out_info->is_low_power_mode = RAC_FALSE; + out_info->core_count = static_cast(std::thread::hardware_concurrency()); + out_info->performance_cores = 0; + out_info->efficiency_cores = 0; + out_info->device_fingerprint = nullptr; // commons falls back to device_id +} + +const char* device_get_id(void* /*user_data*/) { + DeviceBridgeState& state = device_state(); + const char* device_id = rac_state_get_device_id(); + state.device_id = device_id != nullptr ? device_id : ""; + return state.device_id.c_str(); +} + +rac_bool_t device_is_registered(void* /*user_data*/) { + return device_state().registered_this_process ? RAC_TRUE : RAC_FALSE; +} + +void device_set_registered(rac_bool_t registered, void* /*user_data*/) { + device_state().registered_this_process = (registered == RAC_TRUE); +} + +rac_result_t device_http_post(const char* endpoint, const char* json_body, + rac_bool_t requires_auth, rac_device_http_response_t* out_response, + void* /*user_data*/) { + if (endpoint == nullptr || json_body == nullptr || out_response == nullptr) { + return RAC_ERROR_INVALID_ARGUMENT; + } + DeviceBridgeState& state = device_state(); + const HttpResult result = control_plane_post(endpoint, json_body, requires_auth == RAC_TRUE); + state.response_body = result.body; + state.response_error = result.ok() ? std::string() : result.describe(); + + *out_response = {}; + out_response->status_code = result.status; + out_response->response_body = state.response_body.empty() ? nullptr + : state.response_body.c_str(); + if (result.ok()) { + out_response->result = RAC_SUCCESS; + return RAC_SUCCESS; + } + out_response->result = + result.transport != RAC_SUCCESS ? result.transport : RAC_ERROR_HTTP_ERROR; + out_response->error_message = state.response_error.c_str(); + return out_response->result; +} + +} // namespace + +const char* platform_name() { +#if defined(__APPLE__) + return "macos"; +#elif defined(__linux__) + return "linux"; +#elif defined(_WIN32) + return "windows"; +#else + return "desktop"; +#endif +} + +const std::string& device_model() { + static const std::string model = query_device_model(); + return model; +} + +const std::string& os_version_string() { + static const std::string version = query_os_version(); + return version; +} + +void register_device_callbacks() { + rac_device_callbacks_t callbacks = {}; + callbacks.get_device_info = device_get_info; + callbacks.get_device_id = device_get_id; + callbacks.is_registered = device_is_registered; + callbacks.set_registered = device_set_registered; + callbacks.http_post = device_http_post; + callbacks.user_data = nullptr; + if (rac_device_manager_set_callbacks(&callbacks) != RAC_SUCCESS) { + out::status_line("warning: device manager callbacks failed to install"); + } +} + +std::string HttpResult::describe() const { + if (transport != RAC_SUCCESS) { + std::string message = "network error: " + out::describe_result(transport); + if (!body.empty()) { + message += " (" + single_line_preview(body) + ")"; + } + return message; + } + std::string message = "HTTP " + std::to_string(status); + if (!body.empty()) { + message += ": " + single_line_preview(body); + } + return message; +} + +HttpResult control_plane_post(const std::string& endpoint, const std::string& json_body, + bool bearer_auth) { + HttpResult result; + + const char* base_url = rac_state_get_base_url(); + if (base_url == nullptr || base_url[0] == '\0') { + result.transport = RAC_ERROR_INVALID_CONFIGURATION; + result.body = "control-plane base URL is not configured"; + return result; + } + + char url[2048] = {}; + if (rac_build_url(base_url, endpoint.c_str(), url, sizeof(url)) < 0) { + result.transport = RAC_ERROR_INVALID_CONFIGURATION; + result.body = "failed to build control-plane URL"; + return result; + } + + // Canonical control-plane header set — mirrors commons' phase-2 pattern: + // defaults (Content-Type/Accept/X-SDK-*) + X-Platform + apikey [+ Bearer]. + const rac_http_header_kv_t* defaults = nullptr; + size_t default_count = 0; + std::vector headers; + if (rac_http_default_headers(&defaults, &default_count) == RAC_SUCCESS && + defaults != nullptr) { + headers.assign(defaults, defaults + default_count); + } + headers.push_back({"X-Platform", platform_name()}); + const char* api_key = rac_state_get_api_key(); + if (api_key != nullptr && api_key[0] != '\0') { + headers.push_back({"apikey", api_key}); + } + std::string bearer; + if (bearer_auth) { + const char* token = rac_auth_get_access_token(); + if (token != nullptr && token[0] != '\0') { + bearer = std::string("Bearer ") + token; + headers.push_back({"Authorization", bearer.c_str()}); + } + } + + rac_http_client_t* client = nullptr; + rac_result_t rc = rac_http_client_create(&client); + if (rc != RAC_SUCCESS) { + result.transport = rc; + return result; + } + + rac_http_request_t request = {}; + request.method = "POST"; + request.url = url; + request.headers = headers.data(); + request.header_count = headers.size(); + request.body_bytes = reinterpret_cast(json_body.data()); + request.body_len = json_body.size(); + request.timeout_ms = rac_env_default_http_timeout_ms(rac_state_get_environment()); + // Credential-bearing control-plane requests never replay across redirects. + request.follow_redirects = RAC_FALSE; + + rac_http_response_t response = {}; + rc = rac_http_request_send(client, &request, &response); + rac_http_client_destroy(client); + + result.transport = rc; + if (rc == RAC_SUCCESS) { + result.status = response.status; + if (response.body_bytes != nullptr && response.body_len > 0) { + result.body.assign(reinterpret_cast(response.body_bytes), + response.body_len); + } + } + rac_http_response_free(&response); + return result; +} + +rac_result_t login(LoginSummary* out, std::string* error) { + const rac_environment_t env = rac_state_get_environment(); + if (!rac_env_requires_auth(env)) { + if (error != nullptr) { + *error = + "development mode (the default) has no control plane; pass " + "--environment staging (or prod) together with --base-url and --api-key"; + } + return RAC_ERROR_INVALID_CONFIGURATION; + } + + // Step 1: API key → JWT. Idempotent within a process; a valid token + // short-circuits (phase 2 below then takes its authenticated fast path). + if (!rac_auth_is_authenticated() || rac_auth_needs_refresh()) { + const rac_sdk_config_t* config = rac_sdk_get_config(); + if (config == nullptr) { + if (error != nullptr) { + *error = "SDK configuration unavailable (bootstrap did not run?)"; + } + return RAC_ERROR_NOT_INITIALIZED; + } + char* request_json = rac_auth_build_authenticate_request(config); + if (request_json == nullptr) { + if (error != nullptr) { + *error = "failed to build authenticate request"; + } + return RAC_ERROR_INVALID_CONFIGURATION; + } + const HttpResult response = + control_plane_post(RAC_ENDPOINT_AUTHENTICATE, request_json, false); + std::free(request_json); + if (!response.ok()) { + if (error != nullptr) { + *error = "authentication failed: " + response.describe(); + } + return response.transport != RAC_SUCCESS ? response.transport : RAC_ERROR_HTTP_ERROR; + } + const int auth_rc = rac_auth_handle_authenticate_response(response.body.c_str()); + if (auth_rc != RAC_SUCCESS && auth_rc != RAC_ERROR_SECURE_STORAGE_FAILED) { + if (error != nullptr) { + *error = "authentication response rejected: " + single_line_preview(response.body); + } + return RAC_ERROR_INVALID_RESPONSE; + } + } + + // Step 2: canonical phase-2 orchestration — device registration + + // model-assignment fetch (telemetry flush / local rescans stay off; the + // CLI runs those flows through their own commands). + v1::SdkInitPhase2Request request; + const std::string request_bytes = proto::serialize(request); + rac_proto_buffer_t out_buffer; + rac_proto_buffer_init(&out_buffer); + const rac_result_t phase2_rc = rac_sdk_init_phase2_proto( + request_bytes.empty() ? nullptr + : reinterpret_cast(request_bytes.data()), + request_bytes.size(), &out_buffer); + v1::SdkInitResult result; + std::string parse_error; + if (!proto::parse_proto_buffer(&out_buffer, &result, &parse_error) || + phase2_rc != RAC_SUCCESS) { + if (error != nullptr) { + *error = "services init failed: " + + (parse_error.empty() ? out::describe_result(phase2_rc) : parse_error); + } + return phase2_rc != RAC_SUCCESS ? phase2_rc : RAC_ERROR_INVALID_RESPONSE; + } + if (!result.success()) { + if (error != nullptr) { + *error = "services init failed: " + result.error().message(); + } + return RAC_ERROR_INVALID_STATE; + } + + if (out != nullptr) { + const char* organization_id = rac_auth_get_organization_id(); + const char* user_id = rac_auth_get_user_id(); + const char* backend_device_id = rac_auth_get_device_id(); + const char* persistent_device_id = rac_state_get_device_id(); + out->organization_id = organization_id != nullptr ? organization_id : ""; + out->user_id = user_id != nullptr ? user_id : ""; + out->backend_device_id = backend_device_id != nullptr ? backend_device_id : ""; + out->persistent_device_id = persistent_device_id != nullptr ? persistent_device_id : ""; + out->token_expires_at = rac_auth_get_token_expires_at(); + out->device_registered = result.device_registered(); + out->assignment_count = result.linked_models_count(); + out->warning = result.warning(); + } + return RAC_SUCCESS; +} + +} // namespace rcli::net diff --git a/sdk/runanywhere-cli/src/net/control_plane.h b/sdk/runanywhere-cli/src/net/control_plane.h new file mode 100644 index 0000000000..ac2dafce26 --- /dev/null +++ b/sdk/runanywhere-cli/src/net/control_plane.h @@ -0,0 +1,92 @@ +/** + * @file control_plane.h + * @brief Control-plane network wiring for rcli (auth, device, telemetry HTTP). + * + * rcli is the 6th consumer of runanywhere-commons and plays the same role the + * Swift/Kotlin/Flutter/RN/Web bridges play for the control plane: it supplies + * the platform-side callbacks (device info, persistent device id, HTTP POST) + * and drives the canonical commons entry points + * (rac_auth_* + rac_sdk_init_phase2_proto). All handshake sequencing, JSON + * request building, and response parsing stay in commons. + * + * Requires bootstrap() (rac_init + curl transport + rac_state) to have run. + */ + +#ifndef RCLI_NET_CONTROL_PLANE_H +#define RCLI_NET_CONTROL_PLANE_H + +#include +#include + +#include "rac/core/rac_types.h" + +namespace rcli::net { + +/** "macos" / "linux" / "windows" — the X-Platform header + auth payload value. */ +const char* platform_name(); + +/** Best-effort local hardware model (e.g. "Mac16,8"); empty when unknown. */ +const std::string& device_model(); + +/** Best-effort OS version string (kernel release); empty when unknown. */ +const std::string& os_version_string(); + +/** + * Install the CLI's rac_device_callbacks_t: device info gathered from the + * desktop platform adapter, the rac_state persistent device id, an in-process + * registration flag, and an HTTP POST that routes through the registered curl + * transport (Bearer token attached when the request requires auth). + * Idempotent; called from bootstrap(). + */ +void register_device_callbacks(); + +/** One buffered control-plane HTTP exchange. */ +struct HttpResult { + rac_result_t transport = RAC_SUCCESS; ///< send-level result (network/TLS/timeout) + int32_t status = 0; ///< HTTP status (0 when transport failed) + std::string body; ///< response body (server error JSON on 4xx/5xx) + + [[nodiscard]] bool ok() const { + return transport == RAC_SUCCESS && status >= 200 && status < 300; + } + /** "HTTP 401: {...}" / "network error" — for user-facing error lines. */ + [[nodiscard]] std::string describe() const; +}; + +/** + * POST `endpoint` (path, e.g. "/api/v2/sdk/telemetry/llm") against the + * configured base URL with the canonical control-plane headers + * (commons defaults + X-Platform + apikey). When `bearer_auth` is true the + * current JWT access token is attached as `Authorization: Bearer `. + */ +HttpResult control_plane_post(const std::string& endpoint, const std::string& json_body, + bool bearer_auth); + +/** Result of the real auth handshake (authenticate → device → assignments). */ +struct LoginSummary { + std::string organization_id; + std::string user_id; // may be empty (org-scoped keys) + std::string backend_device_id; // control-plane device row id (auth response) + std::string persistent_device_id; // SDK persistent UUID (device fingerprint) + int64_t token_expires_at = 0; // unix seconds + bool device_registered = false; + uint32_t assignment_count = 0; + std::string warning; // non-fatal phase-2 notes +}; + +/** + * Run the real control-plane handshake against the configured backend: + * 1. POST /api/v1/auth/sdk/authenticate (API key → JWT + refresh token), + * 2. rac_sdk_init_phase2_proto (device registration + model-assignment + * fetch through the commons lifecycle orchestrator). + * + * Requires a staging/production environment (development mode has no control + * plane). Idempotent within a process — a valid token short-circuits step 1. + * On failure returns a non-SUCCESS code and fills `error` with the + * server-surfaced message (HTTP status + response body). + */ +rac_result_t login(LoginSummary* out, std::string* error); + +} // namespace rcli::net + +#endif // RCLI_NET_CONTROL_PLANE_H