Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ set(MIN_HTTPLIB_VERSION "0.26.0")
set(MIN_LWS_VERSION "4.3.3")

find_package(PkgConfig QUIET)
find_package(OpenSSL REQUIRED)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't be needed if using MbedTLS


# Try to find system packages with version requirements
find_package(nlohmann_json ${MIN_NLOHMANN_JSON_VERSION} QUIET)
Expand Down
4 changes: 3 additions & 1 deletion src/cpp/cli/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ add_executable(lemonade
target_link_libraries(lemonade PRIVATE
nlohmann_json::nlohmann_json
lemonade-digest-crypto
OpenSSL::SSL
OpenSSL::Crypto
)

# Link httplib based on what's available (set by parent CMakeLists.txt)
Expand Down Expand Up @@ -177,7 +179,7 @@ if(WIN32)
)
endif()

target_compile_definitions(lemonade PRIVATE LEMONADE_CLI)
target_compile_definitions(lemonade PRIVATE LEMONADE_CLI CPPHTTPLIB_OPENSSL_SUPPORT)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we already use MBEDTLS for digest verification, I'd rather not add another dependency and use CPPHTTPLIB_MBEDTLS_SUPPORT instead


# Platform-specific settings
if(WIN32)
Expand Down
70 changes: 63 additions & 7 deletions src/cpp/cli/lemonade_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,57 @@ const std::string& HttpError::response_body() const {
return response_body_;
}

LemonadeClient::LemonadeClient(const std::string& host, int port, const std::string& api_key)
: host_(host), port_(port), api_key_(api_key) {}
void ParseTargetUrl(const std::string& input_host, std::string& out_clean_host, int& out_port, bool& out_is_ssl) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please let's use regular function name convention (lower_case). Also this can be a static function I think?

std::string remaining = input_host;
bool has_scheme = false;

if (remaining.rfind("https://", 0) == 0) {
out_is_ssl = true;
remaining = remaining.substr(8);
has_scheme = true;
} else if (remaining.rfind("http://", 0) == 0) {
out_is_ssl = false;
remaining = remaining.substr(7);
has_scheme = true;
}

// Strip path if present (anything starting with '/')
size_t path_pos = remaining.find('/');
if (path_pos != std::string::npos) {
remaining = remaining.substr(0, path_pos);
}

// Parse port
size_t close_bracket = remaining.find(']');
size_t colon_pos = std::string::npos;
if (remaining.rfind("[", 0) == 0 && close_bracket != std::string::npos) {
size_t post_bracket_colon = remaining.find(':', close_bracket);
if (post_bracket_colon != std::string::npos) {
colon_pos = post_bracket_colon;
}
} else {
colon_pos = remaining.rfind(':');
}

if (colon_pos != std::string::npos) {
out_clean_host = remaining.substr(0, colon_pos);
std::string port_str = remaining.substr(colon_pos + 1);
try {
out_port = std::stoi(port_str);
} catch (...) {
}
} else {
out_clean_host = remaining;
if (has_scheme) {
out_port = out_is_ssl ? 443 : 80;
}
}
}

LemonadeClient::LemonadeClient(const std::string& host, int port, const std::string& api_key, bool is_ssl)
: api_key_(api_key), port_(port), is_ssl_(is_ssl) {
ParseTargetUrl(host, host_, port_, is_ssl_);
}

LemonadeClient::~LemonadeClient() {}

Expand All @@ -94,9 +143,16 @@ std::string LemonadeClient::normalize_host(const std::string& host) const {
}

// Helper to create and configure httplib::Client (timeouts in milliseconds)
static httplib::Client make_client(const std::string& host, int port, const std::string& api_key,
static httplib::Client make_client(const std::string& host, int port, const std::string& api_key, bool is_ssl,
time_t connection_timeout_ms = DEFAULT_CONNECTION_TIMEOUT_MS, time_t read_timeout_ms = DEFAULT_READ_TIMEOUT_MS) {
httplib::Client cli(host, port);
#ifndef CPPHTTPLIB_OPENSSL_SUPPORT

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CPPHTTPLIB_MBEDTLS_SUPPORT

if (is_ssl) {
throw std::runtime_error("HTTPS support is not compiled in this client.");
}
#endif
std::string scheme = is_ssl ? "https" : "http";
std::string url = scheme + "://" + host + ":" + std::to_string(port);
httplib::Client cli(url);
cli.set_connection_timeout(connection_timeout_ms / 1000, (connection_timeout_ms % 1000) * 1000);
cli.set_read_timeout(read_timeout_ms / 1000, (read_timeout_ms % 1000) * 1000);

Expand Down Expand Up @@ -137,7 +193,7 @@ std::string LemonadeClient::make_request(const std::string& path, const std::str
const std::string& body, const std::string& content_type,
time_t connection_timeout_ms, time_t read_timeout_ms) const {
std::string normalized_host = normalize_host(host_);
httplib::Client cli = make_client(normalized_host, port_, api_key_, connection_timeout_ms, read_timeout_ms);
httplib::Client cli = make_client(normalized_host, port_, api_key_, is_ssl_, connection_timeout_ms, read_timeout_ms);

httplib::Result res;

Expand Down Expand Up @@ -225,7 +281,7 @@ bool LemonadeClient::make_request(const std::string& path, const std::string& me
time_t connection_timeout_ms, time_t read_timeout_ms,
std::function<bool()> should_abort) const {
std::string normalized_host = normalize_host(host_);
httplib::Client cli = make_client(normalized_host, port_, api_key_, connection_timeout_ms, read_timeout_ms);
httplib::Client cli = make_client(normalized_host, port_, api_key_, is_ssl_, connection_timeout_ms, read_timeout_ms);

if (method == "POST") {
auto res = handle_sse_stream(cli, path, body, content_type, callback, should_abort);
Expand Down Expand Up @@ -884,7 +940,7 @@ int LemonadeClient::list_recipes(bool show_all) const {
<< "-" << std::endl;
}
} else {
for (const auto& backend : recipe.backends) {
for (const auto& backend : recipe.backends) {
std::string recipe_col = first_backend ? recipe.name : "";
std::string status_str = backend.state.empty() ? "unsupported" : backend.state;

Expand Down
34 changes: 24 additions & 10 deletions src/cpp/cli/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ static void hide_new_options(CLI::App& app, size_t start_index) {
struct CliConfig {
std::string host = "127.0.0.1";
int port = 13305;
bool is_ssl = false;
std::string api_key;
std::string model;
std::string list_filter;
Expand Down Expand Up @@ -270,7 +271,7 @@ static bool try_lemonade_protocol(const std::string& lemonade_url) {
#endif
}

static void open_url(const std::string& host, int port, const std::string& path = "/") {
static void open_url(const std::string& host, int port, const std::string& path = "/", bool is_ssl = false) {
// Map web path to lemonade:// route and try the desktop app first
std::string lemonade_url = "lemonade://open";
if (path == "/?logs=true") {
Expand All @@ -282,7 +283,8 @@ static void open_url(const std::string& host, int port, const std::string& path
}

// Fall back to web app in browser
std::string url = "http://" + host + ":" + std::to_string(port) + path;
std::string scheme = is_ssl ? "https" : "http";
std::string url = scheme + "://" + host + ":" + std::to_string(port) + path;
std::cout << "Opening URL: " << url << std::endl;

#ifdef _WIN32
Expand Down Expand Up @@ -507,7 +509,7 @@ static int handle_run_command(lemonade::LemonadeClient& client, CliConfig& confi
return handle_chat_command(client, config);
}

open_url(config.host, config.port);
open_url(config.host, config.port, "/", config.is_ssl);
return 0;
}

Expand Down Expand Up @@ -712,10 +714,11 @@ static int handle_launch_command(lemonade::LemonadeClient& client, CliConfig& co
std::thread([host = config.host,
port = config.port,
api_key = config.api_key,
is_ssl = config.is_ssl,
model = config.model,
recipe_options = config.recipe_options]() {
try {
lemonade::LemonadeClient async_client(host, port, api_key);
lemonade::LemonadeClient async_client(host, port, api_key, is_ssl);
nlohmann::json request_body = recipe_options;
request_body["model_name"] = model;
request_body["save_options"] = false;
Expand Down Expand Up @@ -748,9 +751,9 @@ static int handle_launch_command(lemonade::LemonadeClient& client, CliConfig& co

// Attempt a quick liveness check against the given host:port
static bool try_live_check(const std::string& host, int port, const std::string& api_key,
int timeout_ms = 500) {
bool is_ssl = false, int timeout_ms = 500) {
try {
lemonade::LemonadeClient client(host, port, api_key);
lemonade::LemonadeClient client(host, port, api_key, is_ssl);
client.make_request("/live", "GET", "", "", timeout_ms, timeout_ms);
return true;
} catch (const std::exception&) {
Expand Down Expand Up @@ -1363,6 +1366,17 @@ int main(int argc, char* argv[]) {
return app.exit(e);
}

// Parse URL scheme and override host, port, is_ssl
{
std::string clean_host;
int clean_port = config.port;
bool is_ssl = false;
lemonade::ParseTargetUrl(config.host, clean_host, clean_port, is_ssl);
config.host = clean_host;
config.port = clean_port;
config.is_ssl = is_ssl;
}

if (load_cmd->count() > 0) {
if (load_cmd->count("--pinned") > 0) {
config.pinned = load_pinned_flag;
Expand All @@ -1388,7 +1402,7 @@ int main(int argc, char* argv[]) {
config.host == "localhost" || config.host == "0.0.0.0");
int live_timeout_ms = is_local ? 100 : 3000;

if (!try_live_check(config.host, config.port, config.api_key, live_timeout_ms)) {
if (!try_live_check(config.host, config.port, config.api_key, config.is_ssl, live_timeout_ms)) {
int discovered_port = discover_local_server_port();
if (discovered_port > 0 && discovered_port != config.port) {
config.port = discovered_port;
Expand All @@ -1403,15 +1417,15 @@ int main(int argc, char* argv[]) {
}

// Create client
lemonade::LemonadeClient client(config.host, config.port, config.api_key);
lemonade::LemonadeClient client(config.host, config.port, config.api_key, config.is_ssl);

// Execute command
if (status_cmd->count() > 0) {
if (config.json_output) {
// Verify the server is actually reachable before reporting its port.
// Without this check, we'd report the default port even when no server is running,
// which could cause callers to target the wrong process.
bool reachable = try_live_check(config.host, config.port, config.api_key, 500);
bool reachable = try_live_check(config.host, config.port, config.api_key, config.is_ssl, 500);
if (!reachable) {
std::cerr << "Server is not running" << std::endl;
return 1;
Expand Down Expand Up @@ -1501,7 +1515,7 @@ int main(int argc, char* argv[]) {
} else if (launch_cmd->count() > 0) {
return handle_launch_command(client, config);
} else if (logs_cmd->count() > 0) {
open_url(config.host, config.port, "/?logs=true");
open_url(config.host, config.port, "/?logs=true", config.is_ssl);
return 0;
} else if (scan_cmd->count() > 0) {
return handle_scan_command(config);
Expand Down
5 changes: 4 additions & 1 deletion src/cpp/include/lemon_cli/lemonade_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class HttpError : public std::runtime_error {

std::string extract_server_error_message(const HttpError& error);

void ParseTargetUrl(const std::string& input_host, std::string& out_clean_host, int& out_port, bool& out_is_ssl);

// Helper struct for streaming request state
struct StreamingRequestState {
std::string last_file;
Expand Down Expand Up @@ -73,7 +75,7 @@ struct RecipeStatus {
// Main CLI client class
class LemonadeClient {
public:
LemonadeClient(const std::string& host, int port, const std::string& api_key);
LemonadeClient(const std::string& host, int port, const std::string& api_key, bool is_ssl = false);
~LemonadeClient();

// Model management commands
Expand Down Expand Up @@ -131,6 +133,7 @@ class LemonadeClient {
std::string host_;
int port_;
std::string api_key_;
bool is_ssl_ = false;
std::string normalize_host(const std::string& host) const;
std::string get_base_url() const;
};
Expand Down
69 changes: 69 additions & 0 deletions test/server_cli2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2157,6 +2157,74 @@ def test_900_launch_docs_match_help_text(self):
self.assertNotIn("--llamacpp", launch_section)


class CLIUrlSchemeTests(unittest.TestCase):
"""Tests URL scheme parsing, HTTPS/TLS connections, and port overrides in the C++ CLI client."""

@classmethod
def setUpClass(cls):
import http.server
import threading
import socket

class MockHTTPHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass

def do_GET(self):
if self.path.startswith("/api/v1/models") or self.path.startswith(
"/api/v0/models"
):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"data":[]}')
else:
self.send_response(404)
self.end_headers()

# Find a free port
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
cls.mock_port = s.getsockname()[1]
s.close()

cls.server = http.server.HTTPServer(
("127.0.0.1", cls.mock_port), MockHTTPHandler
)
cls.thread = threading.Thread(target=cls.server.serve_forever)
cls.thread.daemon = True
cls.thread.start()

@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()

def test_http_scheme_port_default(self):
"""Should connect successfully and default port when http:// scheme is used."""
env = os.environ.copy()
env["LEMONADE_HOST"] = f"http://127.0.0.1:{self.mock_port}"
result = run_cli_command(["list"], env=env, timeout=10)
output = result.stdout + result.stderr
self.assertEqual(result.returncode, 0)
self.assertIn("No models available", output)

def test_https_scheme_connection_attempt(self):
"""Should attempt a secure TLS connection when https:// scheme is used."""
# Connecting https://127.0.0.1:mock_port will attempt a TLS handshake on our HTTP server.
# This will fail (since the server is HTTP), but it verifies that:
# 1. The hostname/port were parsed correctly to 127.0.0.1:mock_port.
# 2. It attempted a TLS handshake.
env = os.environ.copy()
env["LEMONADE_HOST"] = f"https://127.0.0.1:{self.mock_port}"
result = run_cli_command(["list"], env=env, timeout=10)
output = result.stdout + result.stderr
self.assertNotEqual(result.returncode, 0)
# Verify it tried to establish connection or handshake, not throwing a hostname parse error
self.assertIn("Could not connect to Lemonade server", output)


def run_cli_client_tests():
"""Run CLI client tests based on command line arguments."""
args = parse_cli_args()
Expand All @@ -2171,6 +2239,7 @@ def run_cli_client_tests():
suite = unittest.TestSuite()
suite.addTests(loader.loadTestsFromTestCase(PersistentServerCLIClientTests))
suite.addTests(loader.loadTestsFromTestCase(CLIHelpDocsConsistencyTests))
suite.addTests(loader.loadTestsFromTestCase(CLIUrlSchemeTests))

runner = unittest.TextTestRunner(verbosity=2, buffer=False, failfast=True)
result = runner.run(suite)
Expand Down
Loading