diff --git a/docs/lemonade-cli.md b/docs/lemonade-cli.md index f4f104437..0ac28778a 100644 --- a/docs/lemonade-cli.md +++ b/docs/lemonade-cli.md @@ -451,6 +451,8 @@ lemonade launch AGENT [--model MODEL_NAME] [options] | `--model MODEL_NAME` | Model name to launch with. If omitted, you will be prompted to select one. | No | | `--directory DIR` | Remote recipes directory used only if you choose recipe import at prompt | No | | `--recipe-file FILE` | Remote recipe JSON filename used only if you choose recipe import at prompt | No | +| `--provider,-p [PROVIDER]` | Codex only: select provider name for Codex config; Lemonade does not read or modify `config.toml` (defaults to `lemonade`) | No | +| `--agent-args ARGS` | Custom arguments to pass directly to the launched agent process | `""` | | `--ctx-size SIZE` | Context size for the model | `4096` | | `--llamacpp BACKEND` | LlamaCpp backend to use | Auto-detected | | `--llamacpp-args ARGS` | Custom arguments to pass to llama-server (must not conflict with managed args) | `""` | @@ -461,22 +463,39 @@ lemonade launch AGENT [--model MODEL_NAME] [options] - `--directory` and `--recipe-file` are only used for remote recipe import at prompt time. - For local recipe files, run `lemonade import ` first, then launch with the imported model id. - `--api-key` is propagated to the launched agent process. +- For `codex`, launch now injects a Lemonade model provider by default so host/port settings are honored. +- `--provider` is passed directly to Codex as `model_provider`; provider resolution/errors are handled by Codex. +- `--agent-args` is parsed and appended to the launched agent command. - Supported agents: `claude`, `codex` **Examples:** ```bash # Launch an agent with default model settings -lemonade launch claude --model Qwen3-0.6B-GGUF +lemonade launch claude --model Qwen3.5-0.8B-GGUF # Launch an agent with custom context size -lemonade launch claude --model Qwen3-0.6B-GGUF --ctx-size 8192 +lemonade launch claude --model Qwen3.5-0.8B-GGUF --ctx-size 32768 # Launch an agent with a specific llama.cpp backend -lemonade launch codex --model Qwen3-0.6B-GGUF --llamacpp vulkan +lemonade launch codex --model Qwen3.5-0.8B-GGUF --llamacpp vulkan + +# Launch codex using provider from your Codex config.toml (default provider: lemonade) +lemonade launch codex --model Qwen3.5-0.8B-GGUF -p + +# Launch codex using a custom provider name from your Codex config.toml +lemonade launch codex --model Qwen3.5-0.8B-GGUF --provider my-provider # Launch an agent with custom llama.cpp arguments -lemonade launch claude --model Qwen3-0.6B-GGUF --ctx-size 4096 --llamacpp-args "--flash-attn on --no-mmap" +lemonade launch claude --model Qwen3.5-0.8B-GGUF --ctx-size 32768 --llamacpp-args "--flash-attn on --no-mmap" + +# Pass additional arguments directly to the agent +lemonade launch claude --model Qwen3.5-0.8B-GGUF --agent-args "--approval-mode never" + +# Resume from previous session +lemonade launch codex --model Qwen3.5-0.8B-GGUF --agent-args "resume SESSION_ID" + +lemonade launch claude --model Qwen3.5-0.8B-GGUF --agent-args "--resume SESSION_ID" # Launch and allow optional prompt-driven recipe import using prefilled remote recipe flags lemonade launch claude --directory coding-agents --recipe-file Qwen3.5-35B-A3B-NoThinking.json diff --git a/src/cpp/cli/agent_launcher.cpp b/src/cpp/cli/agent_launcher.cpp index 6bbb27bd8..9ab642dcc 100644 --- a/src/cpp/cli/agent_launcher.cpp +++ b/src/cpp/cli/agent_launcher.cpp @@ -91,6 +91,18 @@ std::string build_server_base_url(const std::string& host, int port) { return "http://" + normalize_server_host(host) + ":" + std::to_string(port); } +void append_codex_config_arg(std::vector& args, const std::string& config_value) { + args.push_back("-c"); + args.push_back(config_value); +} + +void append_codex_config_args(std::vector& args, + const std::vector& config_values) { + for (const auto& config_value : config_values) { + append_codex_config_arg(args, config_value); + } +} + void configure_claude_agent(const std::string& base_url, const std::string& model, const std::string& api_key, @@ -130,6 +142,7 @@ void configure_claude_agent(const std::string& base_url, void configure_codex_agent(const std::string& base_url, const std::string& model, const std::string& api_key, + const AgentLaunchOptions& launch_options, AgentConfig& config) { const std::string resolved_api_key = api_key.empty() ? kDefaultAgentApiKey : api_key; @@ -146,17 +159,35 @@ void configure_codex_agent(const std::string& base_url, add_windows_npm_fallbacks(config.fallback_paths, "codex"); config.env_vars = { - {"OPENAI_BASE_URL", base_url + "/v1/"}, {"OPENAI_API_KEY", resolved_api_key}, {"LEMONADE_API_KEY", resolved_api_key} }; - config.extra_args = { - "--oss", - "-m", - model, - "--config", - "web_search=\"disabled\"" + + const std::string responses_base_url = base_url + "/v1"; + const std::string provider_name = launch_options.codex_model_provider.empty() + ? "lemonade" + : launch_options.codex_model_provider; + + + std::vector codex_config_values = { + "model_provider=\"" + provider_name + "\"", + "show_raw_agent_reasoning=true", + "web_search=\"disabled\"", + "analytics.enabled=false", + "feedback.enabled=false" }; + + if (!launch_options.codex_use_user_config) { + codex_config_values.insert(codex_config_values.begin(), + "model_providers." + provider_name + "={ name='Lemonade', base_url='" + responses_base_url + + "', wire_api='responses', env_key='OPENAI_API_KEY', requires_openai_auth=false, supports_websockets=false }"); + } + + config.extra_args = {}; + append_codex_config_args(config.extra_args, codex_config_values); + config.extra_args.push_back("-m"); + config.extra_args.push_back(model); + config.install_instructions = "Install Codex CLI and ensure 'codex' is on PATH."; } @@ -167,6 +198,7 @@ bool build_agent_config(const std::string& agent, int port, const std::string& model, const std::string& api_key, + const AgentLaunchOptions& launch_options, AgentConfig& config, std::string& error_message) { const std::string base = build_server_base_url(host, port); @@ -177,7 +209,7 @@ bool build_agent_config(const std::string& agent, } if (agent == "codex") { - configure_codex_agent(base, model, api_key, config); + configure_codex_agent(base, model, api_key, launch_options, config); return true; } diff --git a/src/cpp/cli/main.cpp b/src/cpp/cli/main.cpp index 7a29cfcb0..c79383534 100644 --- a/src/cpp/cli/main.cpp +++ b/src/cpp/cli/main.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -36,7 +37,6 @@ #include "lemon/utils/aixlog.hpp" - static const std::vector VALID_LABELS = { "coding", "embeddings", @@ -107,6 +107,9 @@ struct CliConfig { bool yes = false; int scan_duration = 30; bool json_output = false; + bool codex_use_user_config = false; + std::string codex_model_provider = "lemonade"; + std::string agent_args; }; // Open a URL via the OS without invoking a shell (avoids shell injection). @@ -335,11 +338,22 @@ static int handle_launch_command(lemonade::LemonadeClient& client, CliConfig& co } lemon_tray::AgentConfig agent_config; + lemon_tray::AgentLaunchOptions launch_options; std::string config_error; + if (config.codex_use_user_config) { + if (config.agent != "codex") { + LOG(ERROR, "AgentBuilder") << "--provider is only supported for the codex agent." << std::endl; + return 1; + } + } + + launch_options.codex_use_user_config = config.codex_use_user_config; + launch_options.codex_model_provider = config.codex_model_provider; + // Build agent config if (!lemon_tray::build_agent_config(config.agent, config.host, config.port, config.model, - config.api_key, + config.api_key, launch_options, agent_config, config_error)) { LOG(ERROR, "AgentBuilder") << "Failed to build agent config: " << config_error << std::endl; return 1; @@ -351,6 +365,11 @@ static int handle_launch_command(lemonade::LemonadeClient& client, CliConfig& co std::cout << "Launch auth: API key provided and propagated to the launched agent." << std::endl; } + if (!config.agent_args.empty()) { + std::vector user_args = lemon::utils::parse_custom_args(config.agent_args); + agent_config.extra_args.insert(agent_config.extra_args.end(), user_args.begin(), user_args.end()); + } + // Find agent binary const std::string agent_binary = lemon_tray::find_agent_binary(agent_config); if (agent_binary.empty()) { @@ -884,6 +903,7 @@ int main(int argc, char* argv[]) { export_cmd->add_option("--output", config.output_file, "Output file path (prints to stdout if not specified)")->type_name("PATH"); // Launch options + CLI::Option* provider_opt = nullptr; launch_cmd->add_option("agent", config.agent, "Agent name to launch") ->type_name("AGENT") ->check(CLI::IsMember(SUPPORTED_AGENTS)); @@ -893,6 +913,15 @@ int main(int argc, char* argv[]) { ->type_name("DIR"); launch_cmd->add_option("--recipe-file", config.recipe_file, "Remote recipe JSON filename used only if you choose recipe import at prompt")->type_name("FILE"); + provider_opt = launch_cmd->add_option("--provider,-p", config.codex_model_provider, + "Use model provider name for Codex instead of Lemonade-injected provider definition") + ->type_name("PROVIDER") + ->default_val(config.codex_model_provider) + ->expected(0, 1); + launch_cmd->add_option("--agent-args", config.agent_args, + "Custom arguments to pass directly to the launched agent process") + ->type_name("ARGS") + ->default_val(config.agent_args); lemon::RecipeOptions::add_cli_options(*launch_cmd, config.recipe_options); // Scan options @@ -900,6 +929,7 @@ int main(int argc, char* argv[]) { // Parse arguments CLI11_PARSE(app, argc, argv); + config.codex_use_user_config = (provider_opt != nullptr && provider_opt->count() > 0); // Auto-discover local server via UDP beacon if the default connection fails // Skip when: no command given, scan command, or user explicitly set --host/--port diff --git a/src/cpp/cli/model_selection.cpp b/src/cpp/cli/model_selection.cpp index 282508d3c..095a83545 100644 --- a/src/cpp/cli/model_selection.cpp +++ b/src/cpp/cli/model_selection.cpp @@ -110,6 +110,26 @@ std::string normalize_agent_key(const std::string& agent_name) { return key; } +bool starts_with_case_insensitive(const std::string& value, const std::string& prefix) { + if (prefix.size() > value.size()) { + return false; + } + + for (size_t i = 0; i < prefix.size(); ++i) { + const char lhs = static_cast(std::tolower(static_cast(value[i]))); + const char rhs = static_cast(std::tolower(static_cast(prefix[i]))); + if (lhs != rhs) { + return false; + } + } + + return true; +} + +bool is_qwen35_family_model(const lemonade::ModelInfo& model) { + return starts_with_case_insensitive(model.id, "Qwen3.5"); +} + std::vector preferred_recipe_directories_for_agent(const std::string& agent_name) { const std::string agent = normalize_agent_key(agent_name); if (agent == "claude" || agent == "codex") { @@ -144,13 +164,21 @@ bool prompt_model_name_input(std::string& model_out) { } std::vector filter_recommended_launch_models( - const std::vector& models) { + const std::vector& models, + const std::string& agent_name) { std::vector filtered; filtered.reserve(models.size()); + const bool exclude_qwen35_for_codex = normalize_agent_key(agent_name) == "codex"; + for (const auto& model : models) { - if (is_recommended_for_launch(model)) { - filtered.push_back(&model); + if (!is_recommended_for_launch(model)) { + continue; + } + if (exclude_qwen35_for_codex && is_qwen35_family_model(model)) { + continue; } + + filtered.push_back(&model); } return filtered; } @@ -167,6 +195,7 @@ bool prompt_launch_recipe_first(lemonade::LemonadeClient& client, MenuState state = MenuState::RecipeDirectories; std::string selected_recipe_dir; + const bool is_codex_agent = normalize_agent_key(agent_name) == "codex"; bool use_preferred_recipe_dir = false; std::string preferred_recipe_dir; bool remote_dirs_loaded = false; @@ -296,6 +325,14 @@ bool prompt_launch_recipe_first(lemonade::LemonadeClient& client, << "' to import and use:" << std::endl; } + if (is_codex_agent) { + std::cout + << "\nWarning: Qwen 3.5 family models currently do not work with Codex due to " + << "a llama.cpp incompatibility. Track upstream: " + << "https://github.com/ggml-org/llama.cpp/issues/20733\n" + << std::endl; + } + if (in_preferred_recipe_dir) { std::cout << " 0) Browse downloaded models" << std::endl; } else { @@ -371,6 +408,14 @@ bool prompt_launch_recipe_first(lemonade::LemonadeClient& client, } } + if (is_codex_agent) { + std::cout + << "\nWarning: Qwen 3.5 family models currently do not work with Codex due to " + << "a llama.cpp incompatibility. Track upstream: " + << "https://github.com/ggml-org/llama.cpp/issues/20733\n" + << std::endl; + } + std::cout << "Browse downloaded llamacpp models:" << std::endl; std::cout << " 0) Browse recommended models (download may be required)" << std::endl; for (size_t i = 0; i < downloaded_llamacpp_models.size(); ++i) { @@ -422,7 +467,7 @@ bool prompt_launch_recipe_first(lemonade::LemonadeClient& client, } std::vector recommended_all = - filter_recommended_launch_models(all_models); + filter_recommended_launch_models(all_models, agent_name); std::cout << "Browse recommended models (llamacpp + hot + tool-calling):" << std::endl; std::cout << " 0) Back to downloaded models" << std::endl; diff --git a/src/cpp/include/lemon_cli/agent_launcher.h b/src/cpp/include/lemon_cli/agent_launcher.h index 4bf980fa5..7be702552 100644 --- a/src/cpp/include/lemon_cli/agent_launcher.h +++ b/src/cpp/include/lemon_cli/agent_launcher.h @@ -15,6 +15,11 @@ struct AgentConfig { std::string install_instructions; }; +struct AgentLaunchOptions { + bool codex_use_user_config = false; + std::string codex_model_provider = "lemonade"; +}; + // Build launcher configuration for a supported agent. // Returns true on success, false if agent is unknown. bool build_agent_config(const std::string& agent, @@ -22,6 +27,7 @@ bool build_agent_config(const std::string& agent, int port, const std::string& model, const std::string& api_key, + const AgentLaunchOptions& launch_options, AgentConfig& config, std::string& error_message); diff --git a/test/server_cli2.py b/test/server_cli2.py index 2c9524d0b..5fcdfefcc 100644 --- a/test/server_cli2.py +++ b/test/server_cli2.py @@ -200,6 +200,7 @@ def _write_fake_agent(self, directory, agent_name, capture_file): def _build_stubbed_agent_env(self, stub_dir): """Build isolated env so PATH resolves fake agents and avoids first-run side effects.""" env = os.environ.copy() + env.pop("OPENAI_BASE_URL", None) env["PATH"] = stub_dir + os.pathsep + env.get("PATH", "") env["HOME"] = stub_dir env["XDG_CONFIG_HOME"] = os.path.join(stub_dir, ".config") @@ -769,10 +770,15 @@ def test_113_launch_codex_with_fake_binary(self): payload = json.load(f) argv = payload["argv"] - self.assertIn("--oss", argv) + self.assertIn("-c", argv) self.assertIn("-m", argv) self.assertIn(ENDPOINT_TEST_MODEL, argv) - self.assertTrue(payload["env"]["OPENAI_BASE_URL"].endswith("/v1/")) + self.assertTrue( + any(arg.startswith("model_providers.lemonade=") for arg in argv), + "Expected injected Lemonade model provider config in codex args", + ) + self.assertIn('model_provider="lemonade"', argv) + self.assertEqual(payload["env"]["OPENAI_BASE_URL"], "") self.assertEqual(payload["env"]["OPENAI_API_KEY"], "lemonade") def test_114_launch_claude_defaults_and_host_normalization(self): @@ -813,8 +819,199 @@ def test_114_launch_claude_defaults_and_host_normalization(self): payload["env"]["ANTHROPIC_BASE_URL"], f"http://localhost:{PORT}" ) - def test_115_launch_with_model_and_directory_flags_is_deterministic(self): - """A provided model should skip import flow even when directory flags are present.""" + def test_102c_launch_codex_provider_default(self): + """Codex launch -p should select default provider without injecting provider config.""" + if IS_WINDOWS: + self.skipTest(WINDOWS_LAUNCH_STUB_SKIP_REASON) + + with tempfile.TemporaryDirectory(prefix="lemonade-launch-stub-") as temp_dir: + capture_path = os.path.join( + temp_dir, "codex_capture_user_config_default.json" + ) + self._write_fake_agent(temp_dir, "codex", capture_path) + env = self._build_stubbed_agent_env(temp_dir) + result = run_cli_command( + [ + "launch", + "codex", + "--model", + ENDPOINT_TEST_MODEL, + "-p", + ], + timeout=TIMEOUT_DEFAULT, + env=env, + ) + + self.assertEqual(result.returncode, 0) + with open(capture_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + argv = payload["argv"] + self.assertIn('model_provider="lemonade"', argv) + self.assertFalse( + any(arg.startswith("model_providers.lemonade=") for arg in argv) + ) + + def test_102d_launch_codex_provider_custom(self): + """Codex launch --provider PROVIDER should target custom provider name.""" + if IS_WINDOWS: + self.skipTest(WINDOWS_LAUNCH_STUB_SKIP_REASON) + + with tempfile.TemporaryDirectory(prefix="lemonade-launch-stub-") as temp_dir: + capture_path = os.path.join( + temp_dir, "codex_capture_user_config_custom.json" + ) + self._write_fake_agent(temp_dir, "codex", capture_path) + env = self._build_stubbed_agent_env(temp_dir) + result = run_cli_command( + [ + "launch", + "codex", + "--model", + ENDPOINT_TEST_MODEL, + "--provider", + "custom-provider", + ], + timeout=TIMEOUT_DEFAULT, + env=env, + ) + + self.assertEqual(result.returncode, 0) + with open(capture_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + argv = payload["argv"] + self.assertIn('model_provider="custom-provider"', argv) + self.assertFalse( + any(arg.startswith("model_providers.custom-provider=") for arg in argv) + ) + + def test_102e_launch_codex_provider_without_config_check(self): + """Codex --provider should not read/validate config.toml in launcher.""" + if IS_WINDOWS: + self.skipTest(WINDOWS_LAUNCH_STUB_SKIP_REASON) + + with tempfile.TemporaryDirectory(prefix="lemonade-launch-stub-") as temp_dir: + capture_path = os.path.join( + temp_dir, "codex_capture_provider_no_config_check.json" + ) + self._write_fake_agent(temp_dir, "codex", capture_path) + env = self._build_stubbed_agent_env(temp_dir) + + result = run_cli_command( + [ + "launch", + "codex", + "--model", + ENDPOINT_TEST_MODEL, + "--provider", + ], + timeout=TIMEOUT_DEFAULT, + env=env, + ) + + self.assertEqual(result.returncode, 0) + with open(capture_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + argv = payload["argv"] + self.assertIn('model_provider="lemonade"', argv) + self.assertFalse( + any(arg.startswith("model_providers.lemonade=") for arg in argv) + ) + + def test_102f_launch_codex_provider_custom_without_config_check(self): + """Codex --provider custom name should not be launcher-validated against config.toml.""" + if IS_WINDOWS: + self.skipTest(WINDOWS_LAUNCH_STUB_SKIP_REASON) + + with tempfile.TemporaryDirectory(prefix="lemonade-launch-stub-") as temp_dir: + capture_path = os.path.join( + temp_dir, "codex_capture_provider_custom_no_config_check.json" + ) + self._write_fake_agent(temp_dir, "codex", capture_path) + env = self._build_stubbed_agent_env(temp_dir) + + result = run_cli_command( + [ + "launch", + "codex", + "--model", + ENDPOINT_TEST_MODEL, + "--provider", + "missing-in-config", + ], + timeout=TIMEOUT_DEFAULT, + env=env, + ) + + self.assertEqual(result.returncode, 0) + with open(capture_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + argv = payload["argv"] + self.assertIn('model_provider="missing-in-config"', argv) + self.assertFalse( + any( + arg.startswith("model_providers.missing-in-config=") for arg in argv + ) + ) + + def test_102g_launch_claude_provider_rejected(self): + """--provider should be rejected for non-codex agents.""" + with tempfile.TemporaryDirectory(prefix="lemonade-launch-stub-") as temp_dir: + env = self._build_missing_agent_env(temp_dir) + result = run_cli_command( + [ + "launch", + "claude", + "--model", + ENDPOINT_TEST_MODEL, + "--provider", + ], + timeout=TIMEOUT_DEFAULT, + env=env, + ) + + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn("only supported for the codex agent", output) + + def test_102h_launch_agent_args_passthrough(self): + """--agent-args should be tokenized and appended to agent argv.""" + if IS_WINDOWS: + self.skipTest(WINDOWS_LAUNCH_STUB_SKIP_REASON) + + with tempfile.TemporaryDirectory(prefix="lemonade-launch-stub-") as temp_dir: + capture_path = os.path.join(temp_dir, "claude_capture_agent_args.json") + self._write_fake_agent(temp_dir, "claude", capture_path) + env = self._build_stubbed_agent_env(temp_dir) + + result = run_cli_command( + [ + "launch", + "claude", + "--model", + ENDPOINT_TEST_MODEL, + "--agent-args", + "--approval-mode never --custom 'a b'", + ], + timeout=TIMEOUT_DEFAULT, + env=env, + ) + + self.assertEqual(result.returncode, 0) + with open(capture_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + argv = payload["argv"] + self.assertIn("--approval-mode", argv) + self.assertIn("never", argv) + self.assertIn("--custom", argv) + self.assertIn("a b", argv) + + def test_103_launch_explicit_model_with_repo_flags_is_deterministic(self): + """Explicit model should skip import flow even when repo flags are present.""" with tempfile.TemporaryDirectory(prefix="lemonade-launch-stub-") as temp_dir: env = self._build_missing_agent_env(temp_dir) @@ -906,6 +1103,14 @@ def test_900_launch_docs_match_help_text(self): "Remote recipe JSON filename used only if you choose recipe import at prompt", help_output, ) + self.assertIn( + "Use model provider name for Codex", + help_output, + ) + self.assertIn( + "Custom arguments to pass directly to the launched agent process", + help_output, + ) docs_path = os.path.join( os.path.dirname(__file__), "..", "docs", "lemonade-cli.md" @@ -926,6 +1131,8 @@ def test_900_launch_docs_match_help_text(self): "For local recipe files, run `lemonade import ` first", docs_text, ) + self.assertIn("--provider,-p [PROVIDER]", docs_text) + self.assertIn("--agent-args ARGS", docs_text) def run_cli_client_tests():