From 8be3d020d9610443c82e9a504c3da5dba3d923ff Mon Sep 17 00:00:00 2001 From: Seun Omonije Date: Sat, 4 Oct 2025 19:31:36 -0700 Subject: [PATCH 1/4] changes for ollama prototype pt 1 --- src/cmd/explain/mod.rs | 2 + src/cmd/explain/network.rs | 104 ++++++++++--------- src/cmd/explain/preflight.rs | 46 +++++++++ src/cmd/explain/run.rs | 41 +++++++- src/cmd/login.rs | 10 +- src/cmd/mod.rs | 1 + src/cmd/new.rs | 1 + src/cmd/prototype/mod.rs | 49 ++++++++- src/cmd/prototype/network.rs | 195 +++++++++++++++++++++++------------ src/cmd/provider.rs | 114 ++++++++++++++++++++ src/common/mod.rs | 3 + src/common/network.rs | 128 +++++++++++++++++++++++ src/config.rs | 3 + src/lib.rs | 1 + src/main.rs | 50 +++++++-- src/util.rs | 30 ++++++ 16 files changed, 642 insertions(+), 136 deletions(-) create mode 100644 src/cmd/explain/preflight.rs create mode 100644 src/cmd/provider.rs create mode 100644 src/common/mod.rs create mode 100644 src/common/network.rs diff --git a/src/cmd/explain/mod.rs b/src/cmd/explain/mod.rs index 5abff8e..a0755da 100644 --- a/src/cmd/explain/mod.rs +++ b/src/cmd/explain/mod.rs @@ -3,7 +3,9 @@ pub mod chunk; pub mod prompts; pub mod renderer; mod network; +mod preflight; pub use run::handle_explain; +pub use preflight::check_explain; diff --git a/src/cmd/explain/network.rs b/src/cmd/explain/network.rs index 767d30d..698cf5f 100644 --- a/src/cmd/explain/network.rs +++ b/src/cmd/explain/network.rs @@ -1,57 +1,69 @@ use anyhow::{Context, Result}; use serde_json::json; +use crate::common::network::{default_client, detect_provider, parse_ollama_text, parse_openai_text, ProviderKind, ollama_chat_url, openai_responses_url, preflight_check}; pub fn call_text_model(api_key: &str, model: &str, system: &str, user: &str) -> Result { use reqwest::blocking::Client; - if api_key.is_empty() { anyhow::bail!("OPENAI_API_KEY is empty"); } - let client = Client::builder() - .timeout(std::time::Duration::from_secs(300)) - .build() - .context("create http client")?; - - // Use Responses API for consistency with existing code - let input = vec![ - json!({"role":"system","content":system}), - json!({"role":"user","content":user}), - ]; - - let resp = client - .post("https://api.openai.com/v1/responses") - .bearer_auth(api_key) - .json(&json!({ - "model": model, - "input": input, - "parallel_tool_calls": false - })) - .send() - .context("send openai request")?; - - let status = resp.status(); - let text = resp.text().unwrap_or_default(); - if !status.is_success() { - anyhow::bail!("OpenAI error {}: {}", status, text); - } - let body: serde_json::Value = serde_json::from_str(&text).context("parse openai json")?; + let provider = detect_provider(); + let use_ollama = provider == ProviderKind::Ollama; - // Prefer output_text, else join message content - if let Some(s) = body.get("output_text").and_then(|v| v.as_str()) { - return Ok(s.to_string()); - } - if let Some(arr) = body.get("output").and_then(|v| v.as_array()) { - // Try to concatenate text parts - let mut buf = String::new(); - for item in arr { - if item.get("type").and_then(|v| v.as_str()) == Some("message") { - if let Some(parts) = item.get("content").and_then(|v| v.as_array()) { - for p in parts { - if let Some(t) = p.get("text").and_then(|t| t.as_str()) { buf.push_str(t); } - } - } - } + if !use_ollama && api_key.is_empty() { anyhow::bail!("OPENAI_API_KEY is empty"); } + let client: Client = default_client(300)?; + preflight_check(&client, provider, model)?; + + if use_ollama { + // Ollama via OpenAI-compatible Chat Completions API + let url = ollama_chat_url(); + let messages = vec![ + json!({"role": "system", "content": system}), + json!({"role": "user", "content": user}), + ]; + + let resp = client + .post(&url) + .json(&json!({ + "model": model, + "messages": messages, + "stream": false + })) + .send() + .context("send ollama chat request")?; + + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Ollama error {}: {}", status, text); + } + let body: serde_json::Value = serde_json::from_str(&text).context("parse ollama json")?; + if let Some(s) = parse_ollama_text(&body) { return Ok(s); } + anyhow::bail!("No text in Ollama response") + } else { + // OpenAI Responses API (existing behavior) + let input = vec![ + json!({"role":"system","content":system}), + json!({"role":"user","content":user}), + ]; + + let resp = client + .post(&openai_responses_url()) + .bearer_auth(api_key) + .json(&json!({ + "model": model, + "input": input, + "parallel_tool_calls": false + })) + .send() + .context("send openai request")?; + + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("OpenAI error {}: {}", status, text); } - if !buf.is_empty() { return Ok(buf); } + let body: serde_json::Value = serde_json::from_str(&text).context("parse openai json")?; + if let Some(s) = parse_openai_text(&body) { return Ok(s); } + anyhow::bail!("No text in OpenAI response") } - anyhow::bail!("No text in OpenAI response") } diff --git a/src/cmd/explain/preflight.rs b/src/cmd/explain/preflight.rs new file mode 100644 index 0000000..011a0ea --- /dev/null +++ b/src/cmd/explain/preflight.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use std::path::PathBuf; + +use crate::config::load_config as load_proj_config; + +pub fn check_explain(files: Vec, _model: Option) -> Result<()> { + if files.is_empty() { anyhow::bail!("no files provided"); } + // Ensure files exist + for f in &files { + let p = std::path::Path::new(f); + if !p.exists() { anyhow::bail!("file not found: {}", f); } + } + + // Resolve model from project config vs CLI + let cwd = std::env::current_dir().unwrap_or(PathBuf::from(".")); + let proj_cfg_path = cwd.join(".qernel").join("qernel.yaml"); + let configured_model = if proj_cfg_path.exists() { + let default_model = crate::util::get_default_explain_model(); + let yaml_model = load_proj_config(&proj_cfg_path) + .ok() + .and_then(|c| c.explain_model); + if let Some(y) = yaml_model.as_ref() { + if y != &default_model { + println!( + "Warning: YAML explain_model '{}' differs from tool default '{}'. YAML takes precedence at runtime.", + y, default_model + ); + } + } + yaml_model.unwrap_or(default_model) + } else { + crate::util::get_default_explain_model() + }; + // For --check, always use configured precedence (YAML -> tool default), + // ignoring the CLI default model value to avoid accidental overrides. + let effective_model = configured_model; + + // Provider preflight + let client = crate::common::network::default_client(10)?; + let provider = crate::common::network::detect_provider(); + crate::common::network::preflight_check(&client, provider, &effective_model)?; + println!("Explain preflight passed for model '{}'.", effective_model); + Ok(()) +} + + diff --git a/src/cmd/explain/run.rs b/src/cmd/explain/run.rs index c32154f..00efadb 100644 --- a/src/cmd/explain/run.rs +++ b/src/cmd/explain/run.rs @@ -6,6 +6,9 @@ use super::prompts::build_snippet_prompt; use super::network::call_text_model; use crate::util::get_openai_api_key_from_env_or_config; use super::renderer::{render_console, render_markdown_report, RenderOptions}; +use std::io::{self, Write}; +// use std::path::Path; // unused +use crate::config::load_config as load_proj_config; use serde::Deserialize; use indicatif::{ProgressBar, ProgressStyle}; @@ -15,7 +18,7 @@ struct SnippetSummary { id: String, summary: String } pub fn handle_explain( files: Vec, per: String, - model: String, + model: Option, markdown: bool, output: Option, pager: bool, @@ -43,6 +46,31 @@ pub fn handle_explain( if let Some(dir) = output_dir.as_ref() { std::fs::create_dir_all(dir).ok(); } + // Resolve effective model: prefer project config's explain_model unless CLI explicitly overrides and user confirms + let cwd = std::env::current_dir().unwrap_or(PathBuf::from(".")); + let proj_cfg_path = cwd.join(".qernel").join("qernel.yaml"); + let configured_model = if proj_cfg_path.exists() { + load_proj_config(&proj_cfg_path) + .ok() + .and_then(|c| c.explain_model) + .unwrap_or_else(|| crate::util::get_default_explain_model()) + } else { + crate::util::get_default_explain_model() + }; + + let effective_model = match model.as_ref() { + Some(cli_model) if cli_model != &configured_model => { + if ask_confirm(&format!( + "Configured explain model is '{}'. Override with CLI model '{}'? [y/N]: ", + configured_model, cli_model + ))? { cli_model.clone() } else { configured_model.clone() } + } + Some(cli_model) => cli_model.clone(), + None => configured_model.clone(), + }; + + println!("Explain model: {}", effective_model); + // For now, sequential per file; we can parallelize later with a concurrency cap. for file in files { let path = PathBuf::from(&file); @@ -83,7 +111,7 @@ pub fn handle_explain( } } - let model_cl = model.clone(); + let model_cl = effective_model.clone(); let api_key_cl = api_key.clone(); let handle = std::thread::spawn(move || { let text = if api_key_cl.is_empty() { @@ -126,4 +154,11 @@ pub fn handle_explain( Ok(()) } - +fn ask_confirm(prompt: &str) -> Result { + print!("{}", prompt); + io::stdout().flush().ok(); + let mut buf = String::new(); + io::stdin().read_line(&mut buf).ok(); + let ans = buf.trim().to_lowercase(); + Ok(ans == "y" || ans == "yes") +} diff --git a/src/cmd/login.rs b/src/cmd/login.rs index 2f83caf..34f5fe1 100644 --- a/src/cmd/login.rs +++ b/src/cmd/login.rs @@ -3,7 +3,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use std::env; use std::io::{self, Read}; -use crate::util::{load_config, save_config, get_openai_api_key_from_env_or_config, set_openai_api_key_in_config, unset_openai_api_key_in_config}; +use crate::util::{load_config, save_config, set_openai_api_key_in_config, unset_openai_api_key_in_config}; use owo_colors::OwoColorize; use reqwest::blocking::Client; use serde::Deserialize; @@ -44,13 +44,7 @@ pub fn handle_auth_with_flags(set_openai_key: bool, unset_openai_key: bool) -> R let masked = if token.len() > 8 { format!("{}...", &token[..8]) } else { "...".to_string() }; println!("{} Personal access token: {}", crate::util::sym_check(ce), masked.blue().bold()); // Also surface OpenAI key status - let has_openai = get_openai_api_key_from_env_or_config().is_some(); - if has_openai { - println!("{} OpenAI API key detected. Note: prototyping uses OpenAI today; we're migrating to Ollama/open-source models soon.", crate::util::sym_check(ce)); - } else { - println!("{} Warning: No OpenAI API key detected. Prototyping features won't be available until a key is set.", crate::util::sym_question(ce)); - println!(" You can set one with: qernel auth --set-openai-key"); - } + // Surface of API key status moved to `qernel provider --show` if let Ok(client) = Client::builder().timeout(std::time::Duration::from_secs(10)).build() { if let Ok(r) = client diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 407f6aa..797dab2 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -4,4 +4,5 @@ pub mod push; pub mod pull; pub mod prototype; pub mod explain; +pub mod provider; diff --git a/src/cmd/new.rs b/src/cmd/new.rs index 9851d3b..dbbf4bf 100644 --- a/src/cmd/new.rs +++ b/src/cmd/new.rs @@ -166,6 +166,7 @@ Implement the algorithms and concepts described in the research paper. benchmarks: crate::config::BenchmarkConfig { test_command: "python -m pytest src/tests.py -v".to_string(), }, + explain_model: Some("codex-mini-latest".to_string()), }; save_config(&config, &qernel_dir.join("qernel.yaml"))?; diff --git a/src/cmd/prototype/mod.rs b/src/cmd/prototype/mod.rs index d1d4ab4..dc47c03 100644 --- a/src/cmd/prototype/mod.rs +++ b/src/cmd/prototype/mod.rs @@ -10,6 +10,7 @@ pub mod validation; use anyhow::{Context, Result}; use std::path::Path; +use std::io::{self, Write}; use crate::config::load_config; use crate::cmd::prototype::logging::{debug_log, init_debug_logging}; @@ -24,10 +25,15 @@ pub fn handle_prototype(cwd: String, model: String, max_iters: u32, debug: bool, let config_path = cwd_abs.join(".qernel").join("qernel.yaml"); let mut config = load_config(&config_path)?; - // Override config with command line arguments if provided - if !model.is_empty() && model != "gpt-5-codex" { - // Only override if a different model was explicitly provided - config.agent.model = model; + // Resolve effective model: prefer project config unless CLI explicitly overrides and user confirms + if !model.is_empty() && model != config.agent.model { + // Prefer YAML precedence; ask to override YAML with CLI configured default + if ask_confirm(&format!( + "Project YAML model is '{}'. Override with CLI model '{}'? [y/N]: ", + config.agent.model, model + ))? { + config.agent.model = model; + } } if max_iters > 0 && max_iters != 15 { // Only override if a different max_iters was explicitly provided @@ -73,6 +79,32 @@ pub fn handle_prototype(cwd: String, model: String, max_iters: u32, debug: bool, ) } +pub fn check_prototype(cwd: String, model: String) -> Result<()> { + let cwd_path = Path::new(&cwd); + let cwd_abs = cwd_path.canonicalize().unwrap_or_else(|_| cwd_path.to_path_buf()); + let config_path = cwd_abs.join(".qernel").join("qernel.yaml"); + let config = load_config(&config_path)?; + + // Warn if YAML model differs from tool default + let tool_default = crate::util::get_default_prototype_model(); + if config_path.exists() && config.agent.model != tool_default { + println!( + "Warning: YAML prototype model '{}' differs from tool default '{}'. YAML takes precedence at runtime.", + config.agent.model, tool_default + ); + } + + // Resolve model from config vs CLI + let effective_model = if !model.is_empty() && model != config.agent.model { model } else { config.agent.model }; + + // Preflight provider + model + let client = crate::common::network::default_client(10)?; + let provider = crate::common::network::detect_provider(); + crate::common::network::preflight_check(&client, provider, &effective_model)?; + println!("Prototype preflight passed for model '{}'.", effective_model); + Ok(()) +} + /// Quickstart: scaffold a project for an arXiv URL then run prototype pub fn quickstart_arxiv(url: String, model: String, max_iters: u32, debug: bool) -> Result<()> { // 1) Derive folder name from arXiv id @@ -126,3 +158,12 @@ fn read_spec_goal(cwd: &Path) -> Result { Ok(spec_content) } + +fn ask_confirm(prompt: &str) -> Result { + print!("{}", prompt); + io::stdout().flush().ok(); + let mut buf = String::new(); + io::stdin().read_line(&mut buf).ok(); + let ans = buf.trim().to_lowercase(); + Ok(ans == "y" || ans == "yes") +} diff --git a/src/cmd/prototype/network.rs b/src/cmd/prototype/network.rs index 7b7dae7..9394a4d 100644 --- a/src/cmd/prototype/network.rs +++ b/src/cmd/prototype/network.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use serde_json::json; +use crate::common::network::{default_client, detect_provider, ollama_chat_url, openai_responses_url, ProviderKind, preflight_check}; use std::{path::PathBuf}; use std::fs; use base64::{Engine as _, engine::general_purpose}; @@ -50,95 +51,129 @@ pub fn make_openai_request_with_images( create_apply_patch_json_tool, // "function" (JSON schema) }; - // Validate API key - if api_key.is_empty() { - anyhow::bail!("OPENAI_API_KEY is empty"); - } - if !api_key.starts_with("sk-") { - anyhow::bail!("OPENAI_API_KEY doesn't look like a valid OpenAI API key (should start with 'sk-')"); + // Provider selection + let provider = detect_provider(); + let use_ollama = provider == ProviderKind::Ollama; + + // Validate API key for OpenAI only + if !use_ollama { + if api_key.is_empty() { + anyhow::bail!("OPENAI_API_KEY is empty"); + } + if !api_key.starts_with("sk-") { + anyhow::bail!("OPENAI_API_KEY doesn't look like a valid OpenAI API key (should start with 'sk-')"); + } } debug_log(debug_file, &format!("[ai] Using API key: {}...", &api_key[..api_key.len().min(10)]), debug_file.is_some()); - let client = Client::builder() - .timeout(std::time::Duration::from_secs(600)) // 10 minute timeout - .build() - .context("Failed to create HTTP client")?; + let client: Client = default_client(600)?; // 10 minute timeout + preflight_check(&client, provider, model)?; - // Select tools based on model - let use_custom_tools = model.starts_with("gpt-5"); // e.g., "gpt-5-codex" - + // Select tools based on model (OpenAI Responses only). Ollama path doesn't use tools. + let use_custom_tools = model.starts_with("gpt-5"); let tools = if use_custom_tools { - // GPT-5 models use custom freeform tools serde_json::to_value(vec![create_apply_patch_freeform_tool()]).expect("tools json") } else { - // codex-mini-latest and other models use JSON function tools serde_json::to_value(vec![create_apply_patch_json_tool()]).expect("tools json") }; debug_log(debug_file, &format!("[ai] tools json: {}", serde_json::to_string_pretty(&tools).unwrap_or_default()), debug_file.is_some()); - // Add retry logic for OpenAI API calls + // Add retry logic for model API calls let mut attempts = 0; let max_attempts = 3; let resp = loop { attempts += 1; - debug_log(debug_file, &format!("[ai] OpenAI API attempt {}/{}", attempts, max_attempts), debug_file.is_some()); - - // Build the input array with optional images - let mut input_array = vec![ - json!({"role": "system", "content": system_prompt}), - ]; + debug_log(debug_file, &format!("[ai] Model API attempt {}/{} (provider={})", attempts, max_attempts, if use_ollama { "ollama" } else { "openai" }), debug_file.is_some()); - // Add user content with optional images - if let Some(image_paths) = &images { - if !image_paths.is_empty() { - debug_log(debug_file, &format!("[ai] attempting to encode {} images for request", image_paths.len()), debug_file.is_some()); - - let mut user_content = vec![json!({"type": "input_text", "text": user_prompt})]; - let mut successful_images = 0; - - // Add each image to the content as base64 data URLs - for image_path in image_paths { - match encode_image_to_base64(image_path) { - Ok(data_url) => { - user_content.push(json!({ - "type": "input_image", - "image_url": data_url - })); - successful_images += 1; - debug_log(debug_file, &format!("[ai] successfully encoded image: {}", image_path), debug_file.is_some()); + let request = if use_ollama { + // Build Chat Completions payload for Ollama + let url = ollama_chat_url(); + + let mut messages = vec![json!({"role": "system", "content": system_prompt})]; + + if let Some(image_paths) = &images { + if !image_paths.is_empty() { + debug_log(debug_file, &format!("[ai] attempting to encode {} images for request", image_paths.len()), debug_file.is_some()); + let mut content_parts = vec![user_prompt.to_string()]; + let mut successful_images = 0; + for image_path in image_paths { + match encode_image_to_base64(image_path) { + Ok(data_url) => { + content_parts.push(format!("\n[image:{}]", data_url)); + successful_images += 1; + debug_log(debug_file, &format!("[ai] successfully encoded image: {}", image_path), debug_file.is_some()); + } + Err(e) => { + debug_log(debug_file, &format!("[ai] failed to encode image {}: {}", image_path, e), debug_file.is_some()); + } } - Err(e) => { - debug_log(debug_file, &format!("[ai] failed to encode image {}: {}", image_path, e), debug_file.is_some()); - // Continue with other images even if one fails + } + debug_log(debug_file, &format!("[ai] successfully encoded {} out of {} images for model request", successful_images, image_paths.len()), debug_file.is_some()); + messages.push(json!({"role": "user", "content": content_parts.join("\n") })); + } else { + messages.push(json!({"role": "user", "content": user_prompt})); + } + } else { + messages.push(json!({"role": "user", "content": user_prompt})); + } + + client + .post(&url) + .json(&json!({ + "model": model, + "messages": messages, + "stream": false + })) + } else { + // Build Responses payload for OpenAI + let mut input_array = vec![ + json!({"role": "system", "content": system_prompt}), + ]; + if let Some(image_paths) = &images { + if !image_paths.is_empty() { + debug_log(debug_file, &format!("[ai] attempting to encode {} images for request", image_paths.len()), debug_file.is_some()); + let mut user_content = vec![json!({"type": "input_text", "text": user_prompt})]; + let mut successful_images = 0; + for image_path in image_paths { + match encode_image_to_base64(image_path) { + Ok(data_url) => { + user_content.push(json!({ + "type": "input_image", + "image_url": data_url + })); + successful_images += 1; + debug_log(debug_file, &format!("[ai] successfully encoded image: {}", image_path), debug_file.is_some()); + } + Err(e) => { + debug_log(debug_file, &format!("[ai] failed to encode image {}: {}", image_path, e), debug_file.is_some()); + } } } + debug_log(debug_file, &format!("[ai] successfully encoded {} out of {} images for model request", successful_images, image_paths.len()), debug_file.is_some()); + input_array.push(json!({ + "role": "user", + "content": user_content + })); + } else { + input_array.push(json!({"role": "user", "content": user_prompt})); } - - debug_log(debug_file, &format!("[ai] successfully encoded {} out of {} images for model request", successful_images, image_paths.len()), debug_file.is_some()); - - input_array.push(json!({ - "role": "user", - "content": user_content - })); } else { input_array.push(json!({"role": "user", "content": user_prompt})); } - } else { - input_array.push(json!({"role": "user", "content": user_prompt})); - } - - let request = client - .post("https://api.openai.com/v1/responses") - .bearer_auth(api_key) - .json(&json!({ - "model": model, - "tools": tools, - "tool_choice": "auto", - "parallel_tool_calls": false, - "input": input_array - })); + + client + .post(&openai_responses_url()) + .bearer_auth(api_key) + .json(&json!({ + "model": model, + "tools": tools, + "tool_choice": "auto", + "parallel_tool_calls": false, + "input": input_array + })) + }; match request.send() { Ok(response) => break response, @@ -178,15 +213,19 @@ pub fn make_openai_request_with_images( } }; - // Check for OpenAI API errors in the response body + // Check for API errors in the response body if let Some(error) = body.get("error") { if let Some(message) = error.get("message").and_then(|m| m.as_str()) { - anyhow::bail!("OpenAI API error: {}", message); + anyhow::bail!("Model API error: {}", message); } } - // Parse the response using the same logic as the original - parse_ai_response(&body, debug_file) + // Dispatch parse based on provider + if use_ollama { + parse_ai_response_ollama(&body, debug_file) + } else { + parse_ai_response(&body, debug_file) + } } fn parse_ai_response(body: &serde_json::Value, debug_file: &Option) -> Result { @@ -332,6 +371,26 @@ fn parse_ai_response(body: &serde_json::Value, debug_file: &Option) -> anyhow::bail!("No actionable tool call or parseable text in response; output types = {:?}", kinds) } +fn parse_ai_response_ollama(body: &serde_json::Value, debug_file: &Option) -> Result { + // Expect OpenAI-compatible chat completions schema + if let Some(content) = body + .get("choices") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|v| v.as_str()) + { + debug_log(debug_file, &format!("[ai] ollama content (to-parse):\n{}", content), debug_file.is_some()); + if let Ok(step) = serde_json::from_str::(content) { + return Ok(step); + } + // If it is not JSON, return as a shell no-op or generic action + return Ok(AiStep { action: "message".to_string(), rationale: None, patch: None, command: Some(content.to_string()) }); + } + anyhow::bail!("No actionable content in Ollama response") +} + /// Encode an image file to base64 data URL fn encode_image_to_base64(image_path: &str) -> Result { // Read the image file diff --git a/src/cmd/provider.rs b/src/cmd/provider.rs new file mode 100644 index 0000000..4453352 --- /dev/null +++ b/src/cmd/provider.rs @@ -0,0 +1,114 @@ +use anyhow::Result; +use clap::Args; +use crate::util::{load_config, save_config, Config, get_openai_api_key_from_env_or_config, sym_check, sym_question, color_enabled_stdout}; + +#[derive(Args)] +pub struct ProviderCmd { + /// Show current provider configuration + #[arg(long)] + pub show: bool, + + /// List available providers + #[arg(long)] + pub list: bool, + + /// Run a preflight check for the current or specified model + #[arg(long)] + pub check: bool, + /// Optional model to check (defaults to current CLI-provided model elsewhere) + #[arg(long)] + pub model: Option, + + /// Set provider: openai | ollama + #[arg(long)] + pub set: Option, + + /// Set Ollama base URL (e.g., http://localhost:11434/v1) + #[arg(long)] + pub base_url: Option, + + /// Set model for a specific command: prototype | explain + #[arg(long)] + pub set_for_cmd: Option, + /// Positional model argument when using --set-for-cmd + pub cmd_model: Option, +} + +pub fn handle_provider(cmd: ProviderCmd) -> Result<()> { + let mut cfg: Config = load_config().unwrap_or_default(); + let ce = color_enabled_stdout(); + if cmd.list { + println!("openai"); + println!("ollama"); + return Ok(()); + } + if cmd.check { + use reqwest::blocking::Client; + use crate::common::network::{default_client, detect_provider, preflight_check}; + let model = cmd.model.as_deref().unwrap_or("codex-mini-latest"); + let client: Client = default_client(15)?; + let provider = detect_provider(); + preflight_check(&client, provider, model)?; + println!("Preflight passed for provider and model '{}'.", model); + return Ok(()); + } + + if let Some(cmd_name) = cmd.set_for_cmd.as_deref() { + let model = cmd.cmd_model.as_deref().unwrap_or(""); + if model.is_empty() { anyhow::bail!("MODEL argument is required: qernel provider --set-for-cmd "); } + // Update user-level qernel defaults (not project YAML) + let mut defaults = load_config().unwrap_or_default(); + match cmd_name.to_lowercase().as_str() { + "prototype" => defaults.default_prototype_model = Some(model.to_string()), + "explain" => defaults.default_explain_model = Some(model.to_string()), + _ => anyhow::bail!("invalid --set-for-cmd '{}': expected 'prototype' or 'explain'", cmd_name), + } + save_config(&defaults)?; + println!("Updated default {} model to '{}' in qernel config.", cmd_name, model); + return Ok(()); + } + let show_mode = cmd.show || (cmd.set.is_none() && cmd.base_url.is_none()); + + if show_mode { + println!("Provider: {}", cfg.provider.as_deref().unwrap_or("openai")); + println!("Ollama_base_url: {}", cfg.ollama_base_url.as_deref().unwrap_or("(unset, default http://localhost:11434/v1)")); + + // Show command-model mapping from tool defaults (not project YAML) + let proto_model = crate::util::get_default_prototype_model(); + let explain_model = crate::util::get_default_explain_model(); + println!("Prototype_model: {}", proto_model); + println!("Explain_model: {}", explain_model); + + let has_openai = get_openai_api_key_from_env_or_config().is_some(); + if has_openai { + println!("{} OpenAI API key detected. Note: prototyping uses OpenAI today; we're migrating to Ollama/open-source models soon.", sym_check(ce)); + } else { + println!("{} Warning: No OpenAI API key detected. Prototyping features won't be available until a key is set.", sym_question(ce)); + println!(" You can set one with: qernel auth --set-openai-key"); + } + return Ok(()); + } + + let mut changed = false; + + if let Some(p) = cmd.set.as_deref() { + let v = p.trim().to_lowercase(); + if v != "openai" && v != "ollama" { + anyhow::bail!("invalid provider '{}': expected 'openai' or 'ollama'", p); + } + cfg.provider = Some(v); + changed = true; + } + + if let Some(url) = cmd.base_url.as_deref() { + let u = url.trim(); + if u.is_empty() { anyhow::bail!("base_url cannot be empty"); } + cfg.ollama_base_url = Some(u.to_string()); + changed = true; + } + + if changed { save_config(&cfg)?; } + Ok(()) +} + + diff --git a/src/common/mod.rs b/src/common/mod.rs new file mode 100644 index 0000000..60cdca1 --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,3 @@ +pub mod network; + + diff --git a/src/common/network.rs b/src/common/network.rs new file mode 100644 index 0000000..7de103e --- /dev/null +++ b/src/common/network.rs @@ -0,0 +1,128 @@ +use anyhow::{Context, Result}; +use reqwest::blocking::Client; +use serde_json::Value; +use std::env; +use crate::util::load_config; +use crate::util::get_openai_api_key_from_env_or_config; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKind { + OpenAI, + Ollama, +} + +pub fn detect_provider() -> ProviderKind { + let env_pick = env::var("QERNEL_PROVIDER").unwrap_or_default().to_lowercase(); + if env_pick == "ollama" { return ProviderKind::Ollama; } + if env_pick == "openai" { return ProviderKind::OpenAI; } + if let Ok(cfg) = load_config() { + if let Some(p) = cfg.provider.as_deref() { + return if p.eq_ignore_ascii_case("ollama") { ProviderKind::Ollama } else { ProviderKind::OpenAI }; + } + } + ProviderKind::OpenAI +} + +pub fn default_client(timeout_secs: u64) -> Result { + Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .context("create http client") +} + +pub fn openai_responses_url() -> String { + "https://api.openai.com/v1/responses".to_string() +} + +pub fn ollama_chat_url() -> String { + let base = env::var("OLLAMA_BASE_URL").ok().filter(|s| !s.trim().is_empty()).or_else(|| { + load_config().ok().and_then(|c| c.ollama_base_url) + }).unwrap_or_else(|| "http://localhost:11434/v1".to_string()); + format!("{}/chat/completions", base.trim_end_matches('/')) +} + +pub fn parse_openai_text(body: &Value) -> Option { + if let Some(s) = body.get("output_text").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + if let Some(arr) = body.get("output").and_then(|v| v.as_array()) { + let mut buf = String::new(); + for item in arr { + if item.get("type").and_then(|v| v.as_str()) == Some("message") { + if let Some(parts) = item.get("content").and_then(|v| v.as_array()) { + for p in parts { + if let Some(t) = p.get("text").and_then(|t| t.as_str()) { + buf.push_str(t); + } + } + } + } + } + if !buf.is_empty() { + return Some(buf); + } + } + None +} + +pub fn parse_ollama_text(body: &Value) -> Option { + body.get("choices") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Preflight check to validate the current provider configuration. +/// - For OpenAI: verifies an API key exists and a simple request schema is accepted. +/// - For Ollama: verifies the chat endpoint is reachable and the model exists. +pub fn preflight_check(client: &Client, provider: ProviderKind, model: &str) -> Result<()> { + match provider { + ProviderKind::OpenAI => { + if get_openai_api_key_from_env_or_config().is_none() { + anyhow::bail!("OPENAI_API_KEY is missing. Set it via env or 'qernel auth --set-openai-key'."); + } + // Minimal schema poke (no request if not desired). We'll do a lightweight HEAD-equivalent via small POST. + let resp = client + .post(&openai_responses_url()) + .bearer_auth(get_openai_api_key_from_env_or_config().unwrap()) + .json(&serde_json::json!({ + "model": model, + "input": [{"role":"system","content":"ping"}], + "max_output_tokens": 1 + })) + .send() + .context("openai preflight request")?; + if resp.status().is_client_error() || resp.status().is_server_error() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + anyhow::bail!("OpenAI preflight failed: {} {}", status, text); + } + Ok(()) + } + ProviderKind::Ollama => { + // Verify endpoint and model availability + let url = ollama_chat_url(); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "model": model, + "messages": [{"role":"system","content":"ping"},{"role":"user","content":"ping"}], + "stream": false, + "max_tokens": 1 + })) + .send() + .context("ollama preflight request")?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + anyhow::bail!("Ollama preflight failed: {} {}", status, text); + } + Ok(()) + } + } +} + + diff --git a/src/config.rs b/src/config.rs index 265bb72..adf31ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub struct QernelConfig { pub papers: Vec, pub content_files: Option>, pub benchmarks: BenchmarkConfig, + #[serde(default)] + pub explain_model: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -49,6 +51,7 @@ impl Default for QernelConfig { benchmarks: BenchmarkConfig { test_command: "python -m pytest src/tests.py -v".to_string(), }, + explain_model: Some("codex-mini-latest".to_string()), } } } diff --git a/src/lib.rs b/src/lib.rs index b258f10..705eb34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod cmd; pub mod config; +pub mod common; pub mod util; diff --git a/src/main.rs b/src/main.rs index 6bad04c..effba4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cmd; mod config; +pub mod common; mod util; use anyhow::Result; @@ -67,6 +68,9 @@ enum Commands { /// OpenAI model to use (e.g., gpt-4o-mini) #[arg(long, default_value = "gpt-5-codex")] model: String, + /// Run preflight checks and exit + #[arg(long)] + check: bool, /// Max iterations for AI loop #[arg(long, default_value_t = 15)] max_iters: u32, @@ -90,9 +94,12 @@ enum Commands { /// Granularity: function | class | block (default: function) #[arg(long, default_value = "function")] per: String, - /// OpenAI model to use (default: codex-mini-latest) - #[arg(long, default_value = "codex-mini-latest")] - model: String, + /// Optional model override (defaults resolved from YAML or tool config) + #[arg(long)] + model: Option, + /// Run preflight checks and exit + #[arg(long)] + check: bool, /// Emit Markdown to .qernel/explain or to --output if provided #[arg(long)] markdown: bool, @@ -106,6 +113,32 @@ enum Commands { #[arg(long)] max_chars: Option, }, + /// Provider operations: show and set provider/base URL + Provider { + /// Show current provider configuration + #[arg(long)] + show: bool, + /// List available providers + #[arg(long)] + list: bool, + /// Run a preflight check for the current or specified model + #[arg(long)] + check: bool, + /// Optional model to check + #[arg(long)] + model: Option, + /// Set provider: openai | ollama + #[arg(long)] + set: Option, + /// Set Ollama base URL (e.g., http://localhost:11434/v1) + #[arg(long)] + base_url: Option, + /// Set model for a specific command: prototype | explain + #[arg(long)] + set_for_cmd: Option, + /// Optional positional model to use with --set-for-cmd + cmd_model: Option, + }, } fn main() -> Result<()> { @@ -115,11 +148,14 @@ fn main() -> Result<()> { Commands::Auth { set_openai_key, unset_openai_key } => cmd::login::handle_auth_with_flags(set_openai_key, unset_openai_key), Commands::Push { remote, url, branch, no_commit } => cmd::push::handle_push(remote, url, branch, no_commit), Commands::Pull { repo, dest, branch, server } => cmd::pull::handle_pull(repo, dest, branch, server), - Commands::Prototype { cwd, model, max_iters, debug, spec_only, spec_and_content_only, arxiv } => { - if let Some(url) = arxiv { cmd::prototype::quickstart_arxiv(url, model, max_iters, debug) } else { cmd::prototype::handle_prototype(cwd, model, max_iters, debug, spec_only, spec_and_content_only) } + Commands::Prototype { cwd, model, check, max_iters, debug, spec_only, spec_and_content_only, arxiv } => { + if check { cmd::prototype::check_prototype(cwd, model) } else if let Some(url) = arxiv { cmd::prototype::quickstart_arxiv(url, model, max_iters, debug) } else { cmd::prototype::handle_prototype(cwd, model, max_iters, debug, spec_only, spec_and_content_only) } + } + Commands::Explain { files, per, model, check, markdown, output, no_pager, max_chars } => { + if check { cmd::explain::check_explain(files, model) } else { cmd::explain::handle_explain(files, per, model, markdown, output, !no_pager, max_chars) } } - Commands::Explain { files, per, model, markdown, output, no_pager, max_chars } => { - cmd::explain::handle_explain(files, per, model, markdown, output, !no_pager, max_chars) + Commands::Provider { show, list, set, base_url, check, model, set_for_cmd, cmd_model } => { + cmd::provider::handle_provider(cmd::provider::ProviderCmd { show, list, set, base_url, check, model, set_for_cmd, cmd_model }) } } } \ No newline at end of file diff --git a/src/util.rs b/src/util.rs index 67c3109..67772bb 100644 --- a/src/util.rs +++ b/src/util.rs @@ -8,6 +8,18 @@ pub struct Config { pub default_server: Option, /// Optional OpenAI API key for prototyping features pub openai_api_key: Option, + /// Provider selection: "openai" or "ollama" + #[serde(default)] + pub provider: Option, + /// Base URL for Ollama when provider is "ollama" + #[serde(default)] + pub ollama_base_url: Option, + /// Default model for the prototype command + #[serde(default)] + pub default_prototype_model: Option, + /// Default model for the explain command + #[serde(default)] + pub default_explain_model: Option, } pub fn load_config() -> Result { @@ -78,5 +90,23 @@ pub fn unset_openai_api_key_in_config() -> Result<()> { save_config(&cfg) } +/// Resolve default prototype model from persisted config or fall back. +pub fn get_default_prototype_model() -> String { + load_config() + .ok() + .and_then(|c| c.default_prototype_model) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "gpt-5-codex".to_string()) +} + +/// Resolve default explain model from persisted config or fall back. +pub fn get_default_explain_model() -> String { + load_config() + .ok() + .and_then(|c| c.default_explain_model) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "codex-mini-latest".to_string()) +} + From 1289c00f19e55e46fc8ecc216ecd007c0bfe772f Mon Sep 17 00:00:00 2001 From: Seun Omonije Date: Sun, 5 Oct 2025 19:47:07 -0700 Subject: [PATCH 2/4] introducing qernel vision --- Cargo.toml | 2 ++ src/cmd/mod.rs | 1 + src/cmd/see.rs | 15 +++++++++++++++ src/main.rs | 3 +++ vision/Cargo.toml | 17 +++++++++++++++++ vision/src/lib.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 85 insertions(+) create mode 100644 src/cmd/see.rs create mode 100644 vision/Cargo.toml create mode 100644 vision/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 210a6d5..70e7afd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ base64 = "0.22" tree-sitter = "0.22" tree-sitter-python = "0.21" once_cell = "1" +qernel-vision = { path = "vision", version = "0.1.0-alpha" } [dev-dependencies] tempfile = "3" @@ -50,6 +51,7 @@ members = [ ".", "src/exe/apply-patch", "src/exe/core", + "vision", ] [workspace.package] diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 407f6aa..3650018 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -4,4 +4,5 @@ pub mod push; pub mod pull; pub mod prototype; pub mod explain; +pub mod see; diff --git a/src/cmd/see.rs b/src/cmd/see.rs new file mode 100644 index 0000000..07d4575 --- /dev/null +++ b/src/cmd/see.rs @@ -0,0 +1,15 @@ +use anyhow::Result; + +/// Open a native window and render a simple HTML string. +pub fn handle_see() -> Result<()> { + // Minimal Hello World HTML + let html = r#" +qernel viewer + +

Hello world

+

qernel vision (macOS) demo

"#; + + qernel_vision::open_html(html) +} + + diff --git a/src/main.rs b/src/main.rs index 6bad04c..89b0049 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,6 +106,8 @@ enum Commands { #[arg(long)] max_chars: Option, }, + /// Open a tiny native window that renders HTML (macOS support today) + See, } fn main() -> Result<()> { @@ -121,5 +123,6 @@ fn main() -> Result<()> { Commands::Explain { files, per, model, markdown, output, no_pager, max_chars } => { cmd::explain::handle_explain(files, per, model, markdown, output, !no_pager, max_chars) } + Commands::See => cmd::see::handle_see(), } } \ No newline at end of file diff --git a/vision/Cargo.toml b/vision/Cargo.toml new file mode 100644 index 0000000..1877c41 --- /dev/null +++ b/vision/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "qernel-vision" +version = "0.1.0-alpha" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +wry = "0.53" +winit = "0.30" +anyhow = "1" + +[lib] +name = "qernel_vision" +path = "src/lib.rs" + + diff --git a/vision/src/lib.rs b/vision/src/lib.rs new file mode 100644 index 0000000..108aee0 --- /dev/null +++ b/vision/src/lib.rs @@ -0,0 +1,47 @@ +use anyhow::Result; +use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + window::{Window, WindowId}, +}; +use wry::WebViewBuilder; + +struct App { + html: String, + window: Option, + _webview: Option, +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let window = event_loop + .create_window(Window::default_attributes().with_title("qernel viewer")) + .expect("create_window"); + + let webview = WebViewBuilder::new() + .with_html(self.html.clone()) + .build(&window) + .expect("build webview"); + + self.window = Some(window); + self._webview = Some(webview); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { + if let WindowEvent::CloseRequested = event { + event_loop.exit(); + } + } +} + +/// Open a small native window that renders the provided HTML string. +/// Currently supports macOS via system WebView. Other platforms may require extra deps. +pub fn open_html(html: &str) -> Result<()> { + let event_loop = EventLoop::new()?; + let mut app = App { html: html.to_string(), window: None, _webview: None }; + event_loop.run_app(&mut app)?; + Ok(()) +} + + From 2e361f19bc587030a33fb883139d48a046b00cd4 Mon Sep 17 00:00:00 2001 From: Seun Omonije Date: Sun, 5 Oct 2025 20:48:37 -0700 Subject: [PATCH 3/4] open the zoo on see --- src/cmd/mod.rs | 1 - src/cmd/see.rs | 33 ++++++++++++++++++-------- src/main.rs | 59 ++++++++++++++--------------------------------- vision/src/lib.rs | 37 +++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 52 deletions(-) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 4e0f3c0..3650018 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -4,6 +4,5 @@ pub mod push; pub mod pull; pub mod prototype; pub mod explain; -pub mod provider; pub mod see; diff --git a/src/cmd/see.rs b/src/cmd/see.rs index 07d4575..12b7326 100644 --- a/src/cmd/see.rs +++ b/src/cmd/see.rs @@ -1,15 +1,30 @@ use anyhow::Result; +use std::env; /// Open a native window and render a simple HTML string. -pub fn handle_see() -> Result<()> { - // Minimal Hello World HTML - let html = r#" -qernel viewer - -

Hello world

-

qernel vision (macOS) demo

"#; - - qernel_vision::open_html(html) +pub fn handle_see(url: Option) -> Result<()> { + // Default to the Qernel Zoo unless overridden by --url or env + let target = url.unwrap_or_else(|| env::var("QERNEL_ZOO_URL").unwrap_or_else(|_| "https://qernelzoo.com".to_string())); + + // Simple in-webview loading HTML that redirects to target URL + let loading = format!(r#" +Loading… + +
+
+
Opening the Qernel Zoo…
+
+ +"#, url=target); + + // Show a very quick loading page, then navigate + // Note: WRY cannot switch page contents after build; we load HTML then change location. + qernel_vision::open_html(&loading) } diff --git a/src/main.rs b/src/main.rs index e9d3b7f..bf2966f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ mod cmd; mod config; -pub mod common; mod util; +pub mod common; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -68,9 +68,6 @@ enum Commands { /// OpenAI model to use (e.g., gpt-4o-mini) #[arg(long, default_value = "gpt-5-codex")] model: String, - /// Run preflight checks and exit - #[arg(long)] - check: bool, /// Max iterations for AI loop #[arg(long, default_value_t = 15)] max_iters: u32, @@ -86,6 +83,9 @@ enum Commands { /// One-shot prototype an arXiv paper URL (creates new project arxiv-) #[arg(long)] arxiv: Option, + /// Run preflight checks and exit + #[arg(long)] + check: bool, }, /// Explain Python source files with snippet-level analysis Explain { @@ -94,12 +94,9 @@ enum Commands { /// Granularity: function | class | block (default: function) #[arg(long, default_value = "function")] per: String, - /// Optional model override (defaults resolved from YAML or tool config) - #[arg(long)] - model: Option, - /// Run preflight checks and exit - #[arg(long)] - check: bool, + /// OpenAI model to use (default: codex-mini-latest) + #[arg(long, default_value = "codex-mini-latest")] + model: String, /// Emit Markdown to .qernel/explain or to --output if provided #[arg(long)] markdown: bool, @@ -112,35 +109,16 @@ enum Commands { /// Max characters per explanation #[arg(long)] max_chars: Option, - }, - /// Provider operations: show and set provider/base URL - Provider { - /// Show current provider configuration - #[arg(long)] - show: bool, - /// List available providers - #[arg(long)] - list: bool, - /// Run a preflight check for the current or specified model + /// Run preflight checks and exit #[arg(long)] check: bool, - /// Optional model to check - #[arg(long)] - model: Option, - /// Set provider: openai | ollama - #[arg(long)] - set: Option, - /// Set Ollama base URL (e.g., http://localhost:11434/v1) - #[arg(long)] - base_url: Option, - /// Set model for a specific command: prototype | explain + }, + /// Open a tiny native window to view the Qernel Zoo or a URL (macOS support today) + See { + /// Open a specific URL (defaults to the Qernel Zoo) #[arg(long)] - set_for_cmd: Option, - /// Optional positional model to use with --set-for-cmd - cmd_model: Option, + url: Option, }, - /// Open a tiny native window that renders HTML (macOS support today) - See, } fn main() -> Result<()> { @@ -150,15 +128,12 @@ fn main() -> Result<()> { Commands::Auth { set_openai_key, unset_openai_key } => cmd::login::handle_auth_with_flags(set_openai_key, unset_openai_key), Commands::Push { remote, url, branch, no_commit } => cmd::push::handle_push(remote, url, branch, no_commit), Commands::Pull { repo, dest, branch, server } => cmd::pull::handle_pull(repo, dest, branch, server), - Commands::Prototype { cwd, model, check, max_iters, debug, spec_only, spec_and_content_only, arxiv } => { + Commands::Prototype { cwd, model, max_iters, debug, spec_only, spec_and_content_only, arxiv, check } => { if check { cmd::prototype::check_prototype(cwd, model) } else if let Some(url) = arxiv { cmd::prototype::quickstart_arxiv(url, model, max_iters, debug) } else { cmd::prototype::handle_prototype(cwd, model, max_iters, debug, spec_only, spec_and_content_only) } } - Commands::Explain { files, per, model, check, markdown, output, no_pager, max_chars } => { - if check { cmd::explain::check_explain(files, model) } else { cmd::explain::handle_explain(files, per, model, markdown, output, !no_pager, max_chars) } - } - Commands::Provider { show, list, set, base_url, check, model, set_for_cmd, cmd_model } => { - cmd::provider::handle_provider(cmd::provider::ProviderCmd { show, list, set, base_url, check, model, set_for_cmd, cmd_model }) + Commands::Explain { files, per, model, markdown, output, no_pager, max_chars, check } => { + if check { cmd::explain::check_explain(files, None) } else { cmd::explain::handle_explain(files, per, Some(model), markdown, output, !no_pager, max_chars) } } - Commands::See => cmd::see::handle_see(), + Commands::See { url } => cmd::see::handle_see(url), } } \ No newline at end of file diff --git a/vision/src/lib.rs b/vision/src/lib.rs index 108aee0..63a3005 100644 --- a/vision/src/lib.rs +++ b/vision/src/lib.rs @@ -44,4 +44,41 @@ pub fn open_html(html: &str) -> Result<()> { Ok(()) } +struct AppUrl { + url: String, + window: Option, + _webview: Option, +} + +impl ApplicationHandler for AppUrl { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let window = event_loop + .create_window(Window::default_attributes().with_title("qernel viewer")) + .expect("create_window"); + + let webview = WebViewBuilder::new() + .with_url(&self.url) + .build(&window) + .expect("build webview"); + + self.window = Some(window); + self._webview = Some(webview); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { + if let WindowEvent::CloseRequested = event { + event_loop.exit(); + } + } +} + +/// Open a small native window and navigate to a URL. +/// macOS uses WebKit. On Linux/Windows, platform prerequisites apply. +pub fn open_url(url: &str) -> Result<()> { + let event_loop = EventLoop::new()?; + let mut app = AppUrl { url: url.to_string(), window: None, _webview: None }; + event_loop.run_app(&mut app)?; + Ok(()) +} + From 3ab6bfaf2f909254a471b8c9b12180b182627b55 Mon Sep 17 00:00:00 2001 From: Seun Omonije Date: Mon, 6 Oct 2025 12:49:15 -0700 Subject: [PATCH 4/4] migration away from openai to qernel model infra --- README.md | 12 +++---- src/cmd/explain/network.rs | 48 ++++++++++++++++++++----- src/cmd/explain/run.rs | 9 +---- src/cmd/mod.rs | 1 + src/cmd/prototype/network.rs | 62 ++++++++++++++++++++++---------- src/cmd/provider.rs | 69 ++++++++++++++++++++++++++++-------- src/common/network.rs | 46 ++++++++++++------------ src/main.rs | 40 ++++++++++++++++++--- src/util.rs | 17 +++++++-- 9 files changed, 216 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 940410e..8cbb1d2 100644 --- a/README.md +++ b/README.md @@ -115,16 +115,9 @@ You can also output results of the file to Markdown by adding the `--markdown` f ### Limitations -- This project currently relies on AI models that are not optimized for quantum computing concepts/programming, and therefore may not always produce accurate results. **We are actively working to solve this issue.** However, we've seen strong potential in AI models to mathetmatically reason (see [here](https://deepmind.google/discover/blog/advanced-version-of-gemini-with-deep-think-officially-achieves-gold-medal-standard-at-the-international-mathematical-olympiad/), [here](https://x.com/alexwei_/status/1946477742855532918)), and expect this accuracy gap to decrease over time. -- The core infrastructure logic to edit and maintain files in a repository was ported over from the [Codex CLI](https://github.com/openai/codex), and as a result, currently only works with OpenAI models. The [main agent loop]() natively supports the `codex-mini-latest` and `gpt-5-codex` models, if you'd like to use another model, you might need to edit the code and rebuild until we extend the support. -- You currently need to use your own OpenAI API key to access the models, you can create an account and get one from the [OpenAI API Platform site](https://platform.openai.com/docs/overview). -- We're actively working to migrate away from the OpenAI API to [Ollama](https://ollama.com), which will allow you to run your own models locally on your computer, access a suite of open source models, or use a cloud model if you wish. - +- This project currently relies on both open and closed-source models that are not optimized for quantum computing concepts/programming, and therefore may not always produce accurate results. **We are actively working to solve this issue.** However, we've seen strong potential in AI models to mathetmatically reason (see [here](https://deepmind.google/discover/blog/advanced-version-of-gemini-with-deep-think-officially-achieves-gold-medal-standard-at-the-international-mathematical-olympiad/), [here](https://x.com/alexwei_/status/1946477742855532918)), and expect this accuracy gap to decrease over time. ### Tips for best performance - -- For one-shot prototyping of arXiv papers, it's highly recommended to use `gpt-5-codex` with detailed implementation notes in `spec.md`. The model is surprisingly good if you tell it exactly what you want. The drawbacks are that it's expensive. -- For smaller examples that do not involve high context, `codex-mini-latest` with a well formatted spec file and well written tests can often get the job done. - The agents looks at the tests in `src/tests.py` to form its implementation, and will automatically run its solutions against the test suite upon each iteration to mold its implementation. You can set the `--max-iter` flag to limit how many times it tries. Simple tests can significantly improve and speed up the implemetation process. ### Cloning and sharing projects @@ -142,3 +135,6 @@ qernel push qernel pull ``` +### Acknowledgements +- The core infrastructure logic to edit and maintain files in a repository was ported over from the [Codex CLI](https://github.com/openai/codex). Thank you OpenAI team for making this public and under a permissive license. +- Thank you [Linus Torvalds](https://en.wikipedia.org/wiki/Linus_Torvalds) for building the foundation of modern software, and serving as an inspiration an example for the power of open source work. \ No newline at end of file diff --git a/src/cmd/explain/network.rs b/src/cmd/explain/network.rs index 698cf5f..63591e8 100644 --- a/src/cmd/explain/network.rs +++ b/src/cmd/explain/network.rs @@ -1,13 +1,15 @@ use anyhow::{Context, Result}; use serde_json::json; -use crate::common::network::{default_client, detect_provider, parse_ollama_text, parse_openai_text, ProviderKind, ollama_chat_url, openai_responses_url, preflight_check}; +use crate::common::network::{default_client, detect_provider, parse_ollama_text, parse_model_text, ProviderKind, ollama_chat_url, qernel_model_url, preflight_check}; +use crate::util::get_qernel_pat_from_env_or_config; pub fn call_text_model(api_key: &str, model: &str, system: &str, user: &str) -> Result { use reqwest::blocking::Client; let provider = detect_provider(); let use_ollama = provider == ProviderKind::Ollama; + let use_qernel = provider == ProviderKind::Qernel; - if !use_ollama && api_key.is_empty() { anyhow::bail!("OPENAI_API_KEY is empty"); } + if !(use_ollama || use_qernel) && api_key.is_empty() { anyhow::bail!("OPENAI_API_KEY is empty"); } let client: Client = default_client(300)?; preflight_check(&client, provider, model)?; @@ -37,6 +39,33 @@ pub fn call_text_model(api_key: &str, model: &str, system: &str, user: &str) -> let body: serde_json::Value = serde_json::from_str(&text).context("parse ollama json")?; if let Some(s) = parse_ollama_text(&body) { return Ok(s); } anyhow::bail!("No text in Ollama response") + } else if use_qernel { + // Qernel model endpoint: minimal Responses-like payload + let input = vec![ + json!({"role":"system","content":system}), + json!({"role":"user","content":user}), + ]; + let url = qernel_model_url(); + let mut req = client.post(&url); + if let Some(pat) = get_qernel_pat_from_env_or_config() { + req = req.bearer_auth(pat); + } + let resp = req + .json(&json!({ + "model": model, + "input": input + })) + .send() + .context("send qernel request")?; + + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Qernel error {}: {}", status, text); + } + let body: serde_json::Value = serde_json::from_str(&text).context("parse qernel json")?; + if let Some(s) = parse_model_text(&body) { return Ok(s); } + anyhow::bail!("No text in Qernel response") } else { // OpenAI Responses API (existing behavior) let input = vec![ @@ -44,25 +73,26 @@ pub fn call_text_model(api_key: &str, model: &str, system: &str, user: &str) -> json!({"role":"user","content":user}), ]; + // Default path: Qernel Responses-compatible endpoint + let url = qernel_model_url(); let resp = client - .post(&openai_responses_url()) - .bearer_auth(api_key) + .post(&url) .json(&json!({ "model": model, "input": input, "parallel_tool_calls": false })) .send() - .context("send openai request")?; + .context("send qernel request")?; let status = resp.status(); let text = resp.text().unwrap_or_default(); if !status.is_success() { - anyhow::bail!("OpenAI error {}: {}", status, text); + anyhow::bail!("Qernel error {}: {}", status, text); } - let body: serde_json::Value = serde_json::from_str(&text).context("parse openai json")?; - if let Some(s) = parse_openai_text(&body) { return Ok(s); } - anyhow::bail!("No text in OpenAI response") + let body: serde_json::Value = serde_json::from_str(&text).context("parse qernel json")?; + if let Some(s) = parse_model_text(&body) { return Ok(s); } + anyhow::bail!("No text in Qernel response") } } diff --git a/src/cmd/explain/run.rs b/src/cmd/explain/run.rs index 00efadb..c9daa31 100644 --- a/src/cmd/explain/run.rs +++ b/src/cmd/explain/run.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use super::chunk::{ChunkGranularity, PythonChunk, chunk_python_or_fallback}; use super::prompts::build_snippet_prompt; use super::network::call_text_model; -use crate::util::get_openai_api_key_from_env_or_config; use super::renderer::{render_console, render_markdown_report, RenderOptions}; use std::io::{self, Write}; // use std::path::Path; // unused @@ -86,7 +85,6 @@ pub fn handle_explain( let snippets: Vec = chunk_python_or_fallback(&content, &path, granularity)?; // Concurrent per-snippet calls (bounded) - let api_key = get_openai_api_key_from_env_or_config().unwrap_or_default(); let max_workers = std::env::var("QERNEL_EXPLAIN_WORKERS").ok().and_then(|s| s.parse::().ok()).unwrap_or(4); let mut handles: Vec> = Vec::new(); @@ -112,13 +110,8 @@ pub fn handle_explain( } let model_cl = effective_model.clone(); - let api_key_cl = api_key.clone(); let handle = std::thread::spawn(move || { - let text = if api_key_cl.is_empty() { - super::prompts::mock_call_model(&model_cl, &system, &user).unwrap_or_else(|_| "(mock explanation)".to_string()) - } else { - call_text_model(&api_key_cl, &model_cl, &system, &user).unwrap_or_else(|e| format!("(error: {})", e)) - }; + let text = call_text_model("", &model_cl, &system, &user).unwrap_or_else(|e| format!("(error: {})", e)); (idx, text) }); handles.insert(0, handle); diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 3650018..b80d7bc 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -5,4 +5,5 @@ pub mod pull; pub mod prototype; pub mod explain; pub mod see; +pub mod provider; diff --git a/src/cmd/prototype/network.rs b/src/cmd/prototype/network.rs index 9394a4d..e5661b4 100644 --- a/src/cmd/prototype/network.rs +++ b/src/cmd/prototype/network.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use serde_json::json; -use crate::common::network::{default_client, detect_provider, ollama_chat_url, openai_responses_url, ProviderKind, preflight_check}; +use crate::common::network::{default_client, detect_provider, ollama_chat_url, qernel_model_url, ProviderKind, preflight_check}; +use crate::util::get_qernel_pat_from_env_or_config; use std::{path::PathBuf}; use std::fs; use base64::{Engine as _, engine::general_purpose}; @@ -54,16 +55,9 @@ pub fn make_openai_request_with_images( // Provider selection let provider = detect_provider(); let use_ollama = provider == ProviderKind::Ollama; + let use_qernel = provider == ProviderKind::Qernel; - // Validate API key for OpenAI only - if !use_ollama { - if api_key.is_empty() { - anyhow::bail!("OPENAI_API_KEY is empty"); - } - if !api_key.starts_with("sk-") { - anyhow::bail!("OPENAI_API_KEY doesn't look like a valid OpenAI API key (should start with 'sk-')"); - } - } + // No API key validation required for Qernel/Ollama debug_log(debug_file, &format!("[ai] Using API key: {}...", &api_key[..api_key.len().min(10)]), debug_file.is_some()); let client: Client = default_client(600)?; // 10 minute timeout @@ -85,7 +79,7 @@ pub fn make_openai_request_with_images( let max_attempts = 3; let resp = loop { attempts += 1; - debug_log(debug_file, &format!("[ai] Model API attempt {}/{} (provider={})", attempts, max_attempts, if use_ollama { "ollama" } else { "openai" }), debug_file.is_some()); + debug_log(debug_file, &format!("[ai] Model API attempt {}/{} (provider={})", attempts, max_attempts, if use_ollama { "ollama" } else { "qernel" }), debug_file.is_some()); let request = if use_ollama { // Build Chat Completions payload for Ollama @@ -126,6 +120,37 @@ pub fn make_openai_request_with_images( "messages": messages, "stream": false })) + } else if use_qernel { + // Use Qernel model endpoint with Responses-like payload + let mut input_array = vec![ + json!({"role": "system", "content": system_prompt}), + ]; + if let Some(image_paths) = &images { + if !image_paths.is_empty() { + let mut user_content = vec![json!({"type": "input_text", "text": user_prompt})]; + for image_path in image_paths { + if let Ok(data_url) = encode_image_to_base64(image_path) { + user_content.push(json!({"type": "input_image", "image_url": data_url})); + } + } + input_array.push(json!({"role": "user", "content": user_content})); + } else { + input_array.push(json!({"role": "user", "content": user_prompt})); + } + } else { + input_array.push(json!({"role": "user", "content": user_prompt})); + } + + let url = qernel_model_url(); + let mut req = client.post(&url); + if let Some(pat) = get_qernel_pat_from_env_or_config() { + req = req.bearer_auth(pat); + } + req + .json(&json!({ + "model": model, + "input": input_array + })) } else { // Build Responses payload for OpenAI let mut input_array = vec![ @@ -163,9 +188,10 @@ pub fn make_openai_request_with_images( input_array.push(json!({"role": "user", "content": user_prompt})); } + // Default path: Qernel Responses-compatible endpoint + let url = qernel_model_url(); client - .post(&openai_responses_url()) - .bearer_auth(api_key) + .post(&url) .json(&json!({ "model": model, "tools": tools, @@ -189,19 +215,19 @@ pub fn make_openai_request_with_images( }; let status = resp.status(); - debug_log(debug_file, &format!("[ai] openai status: {}", status), debug_file.is_some()); + debug_log(debug_file, &format!("[ai] model status: {}", status), debug_file.is_some()); // Check for API errors if !status.is_success() { let error_text = resp.text().unwrap_or_default(); - anyhow::bail!("OpenAI API error ({}): {}", status, error_text); + anyhow::bail!("Model API error ({}): {}", status, error_text); } - let raw = resp.text().context("openai response text")?; - debug_log(debug_file, &format!("[ai] openai body length: {} chars", raw.len()), debug_file.is_some()); + let raw = resp.text().context("model response text")?; + debug_log(debug_file, &format!("[ai] model body length: {} chars", raw.len()), debug_file.is_some()); // Debug: Print the raw response for troubleshooting - debug_log(debug_file, &format!("[ai] openai raw response:\n{}", raw), false); + debug_log(debug_file, &format!("[ai] raw response:\n{}", raw), false); // Parse response with better error handling let body: serde_json::Value = match serde_json::from_str(&raw) { diff --git a/src/cmd/provider.rs b/src/cmd/provider.rs index 4453352..d89767a 100644 --- a/src/cmd/provider.rs +++ b/src/cmd/provider.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Args; -use crate::util::{load_config, save_config, Config, get_openai_api_key_from_env_or_config, sym_check, sym_question, color_enabled_stdout}; +use crate::util::{load_config, save_config, Config}; #[derive(Args)] pub struct ProviderCmd { @@ -32,26 +32,73 @@ pub struct ProviderCmd { pub set_for_cmd: Option, /// Positional model argument when using --set-for-cmd pub cmd_model: Option, + + /// Interactive picker to choose provider and default models + #[arg(long)] + pub pick: bool, } pub fn handle_provider(cmd: ProviderCmd) -> Result<()> { let mut cfg: Config = load_config().unwrap_or_default(); - let ce = color_enabled_stdout(); if cmd.list { - println!("openai"); + println!("qernel"); println!("ollama"); return Ok(()); } if cmd.check { use reqwest::blocking::Client; use crate::common::network::{default_client, detect_provider, preflight_check}; - let model = cmd.model.as_deref().unwrap_or("codex-mini-latest"); + let model = cmd.model.as_deref().unwrap_or("qernel-auto"); let client: Client = default_client(15)?; let provider = detect_provider(); preflight_check(&client, provider, model)?; println!("Preflight passed for provider and model '{}'.", model); return Ok(()); } + if cmd.pick { + use std::io::{self, Write}; + fn prompt(prompt: &str) -> String { + print!("{}", prompt); + io::stdout().flush().ok(); + let mut s = String::new(); + io::stdin().read_line(&mut s).ok(); + s.trim().to_string() + } + + println!("Select provider:"); + println!(" 1) qernel"); + println!(" 2) ollama"); + let choice = prompt("Enter number [1]: "); + let provider = match choice.as_str() { + "2" => "ollama", + _ => "qernel", + }; + cfg.provider = Some(provider.to_string()); + + let (default_proto, default_explain) = match provider { + "qernel" => ("qernel-auto", "qernel-auto"), + "ollama" => ("llama3.1:8b", "llama3.1:8b"), + _ => ("qernel-auto", "qernel-auto"), + }; + + let proto = prompt(&format!("Prototype model [{}]: ", default_proto)); + let explain = prompt(&format!("Explain model [{}]: ", default_explain)); + cfg.default_prototype_model = Some(if proto.is_empty() { default_proto.to_string() } else { proto }); + cfg.default_explain_model = Some(if explain.is_empty() { default_explain.to_string() } else { explain }); + + if provider == "ollama" { + let base = prompt(&format!("Ollama base URL [{}]: ", cfg.ollama_base_url.as_deref().unwrap_or("http://localhost:11434/v1"))); + if !base.trim().is_empty() { cfg.ollama_base_url = Some(base); } + } + + save_config(&cfg)?; + println!("Saved provider '{}' with prototype='{}' and explain='{}'.", + provider, + cfg.default_prototype_model.as_deref().unwrap_or(""), + cfg.default_explain_model.as_deref().unwrap_or("") + ); + return Ok(()); + } if let Some(cmd_name) = cmd.set_for_cmd.as_deref() { let model = cmd.cmd_model.as_deref().unwrap_or(""); @@ -70,7 +117,7 @@ pub fn handle_provider(cmd: ProviderCmd) -> Result<()> { let show_mode = cmd.show || (cmd.set.is_none() && cmd.base_url.is_none()); if show_mode { - println!("Provider: {}", cfg.provider.as_deref().unwrap_or("openai")); + println!("Provider: {}", cfg.provider.as_deref().unwrap_or("qernel")); println!("Ollama_base_url: {}", cfg.ollama_base_url.as_deref().unwrap_or("(unset, default http://localhost:11434/v1)")); // Show command-model mapping from tool defaults (not project YAML) @@ -78,14 +125,6 @@ pub fn handle_provider(cmd: ProviderCmd) -> Result<()> { let explain_model = crate::util::get_default_explain_model(); println!("Prototype_model: {}", proto_model); println!("Explain_model: {}", explain_model); - - let has_openai = get_openai_api_key_from_env_or_config().is_some(); - if has_openai { - println!("{} OpenAI API key detected. Note: prototyping uses OpenAI today; we're migrating to Ollama/open-source models soon.", sym_check(ce)); - } else { - println!("{} Warning: No OpenAI API key detected. Prototyping features won't be available until a key is set.", sym_question(ce)); - println!(" You can set one with: qernel auth --set-openai-key"); - } return Ok(()); } @@ -93,8 +132,8 @@ pub fn handle_provider(cmd: ProviderCmd) -> Result<()> { if let Some(p) = cmd.set.as_deref() { let v = p.trim().to_lowercase(); - if v != "openai" && v != "ollama" { - anyhow::bail!("invalid provider '{}': expected 'openai' or 'ollama'", p); + if v != "ollama" && v != "qernel" { + anyhow::bail!("invalid provider '{}': expected 'qernel' or 'ollama'", p); } cfg.provider = Some(v); changed = true; diff --git a/src/common/network.rs b/src/common/network.rs index 7de103e..2c5ebc4 100644 --- a/src/common/network.rs +++ b/src/common/network.rs @@ -2,25 +2,23 @@ use anyhow::{Context, Result}; use reqwest::blocking::Client; use serde_json::Value; use std::env; -use crate::util::load_config; -use crate::util::get_openai_api_key_from_env_or_config; +use crate::util::{load_config, get_qernel_pat_from_env_or_config}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProviderKind { - OpenAI, - Ollama, -} +pub enum ProviderKind { Qernel, Ollama } pub fn detect_provider() -> ProviderKind { let env_pick = env::var("QERNEL_PROVIDER").unwrap_or_default().to_lowercase(); if env_pick == "ollama" { return ProviderKind::Ollama; } - if env_pick == "openai" { return ProviderKind::OpenAI; } + if env_pick == "qernel" { return ProviderKind::Qernel; } if let Ok(cfg) = load_config() { if let Some(p) = cfg.provider.as_deref() { - return if p.eq_ignore_ascii_case("ollama") { ProviderKind::Ollama } else { ProviderKind::OpenAI }; + if p.eq_ignore_ascii_case("ollama") { return ProviderKind::Ollama; } + if p.eq_ignore_ascii_case("qernel") { return ProviderKind::Qernel; } + return ProviderKind::Qernel; } } - ProviderKind::OpenAI + ProviderKind::Qernel } pub fn default_client(timeout_secs: u64) -> Result { @@ -30,8 +28,8 @@ pub fn default_client(timeout_secs: u64) -> Result { .context("create http client") } -pub fn openai_responses_url() -> String { - "https://api.openai.com/v1/responses".to_string() +pub fn qernel_model_url() -> String { + "".to_string() // Will need to fill this with the server URL when ready } pub fn ollama_chat_url() -> String { @@ -41,7 +39,7 @@ pub fn ollama_chat_url() -> String { format!("{}/chat/completions", base.trim_end_matches('/')) } -pub fn parse_openai_text(body: &Value) -> Option { +pub fn parse_model_text(body: &Value) -> Option { if let Some(s) = body.get("output_text").and_then(|v| v.as_str()) { return Some(s.to_string()); } @@ -76,29 +74,29 @@ pub fn parse_ollama_text(body: &Value) -> Option { } /// Preflight check to validate the current provider configuration. -/// - For OpenAI: verifies an API key exists and a simple request schema is accepted. +/// - For Qernel: verifies the endpoint is reachable. /// - For Ollama: verifies the chat endpoint is reachable and the model exists. pub fn preflight_check(client: &Client, provider: ProviderKind, model: &str) -> Result<()> { match provider { - ProviderKind::OpenAI => { - if get_openai_api_key_from_env_or_config().is_none() { - anyhow::bail!("OPENAI_API_KEY is missing. Set it via env or 'qernel auth --set-openai-key'."); + ProviderKind::Qernel => { + // Verify the local/remote qernel model endpoint is reachable. + let url = qernel_model_url(); + let mut req = client.post(&url); + if let Some(pat) = get_qernel_pat_from_env_or_config() { + req = req.bearer_auth(pat); } - // Minimal schema poke (no request if not desired). We'll do a lightweight HEAD-equivalent via small POST. - let resp = client - .post(&openai_responses_url()) - .bearer_auth(get_openai_api_key_from_env_or_config().unwrap()) + let resp = req .json(&serde_json::json!({ "model": model, "input": [{"role":"system","content":"ping"}], - "max_output_tokens": 1 + "max_output_tokens": 16 })) .send() - .context("openai preflight request")?; - if resp.status().is_client_error() || resp.status().is_server_error() { + .context("qernel preflight request")?; + if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); - anyhow::bail!("OpenAI preflight failed: {} {}", status, text); + anyhow::bail!("Qernel preflight failed: {} {}", status, text); } Ok(()) } diff --git a/src/main.rs b/src/main.rs index bf2966f..3974ba0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,8 +65,8 @@ enum Commands { /// Working directory #[arg(long, default_value = ".")] cwd: String, - /// OpenAI model to use (e.g., gpt-4o-mini) - #[arg(long, default_value = "gpt-5-codex")] + /// Model to use (default depends on provider) + #[arg(long, default_value = "qernel-auto")] model: String, /// Max iterations for AI loop #[arg(long, default_value_t = 15)] @@ -94,8 +94,8 @@ enum Commands { /// Granularity: function | class | block (default: function) #[arg(long, default_value = "function")] per: String, - /// OpenAI model to use (default: codex-mini-latest) - #[arg(long, default_value = "codex-mini-latest")] + /// Model to use (default depends on provider) + #[arg(long, default_value = "qernel-auto")] model: String, /// Emit Markdown to .qernel/explain or to --output if provided #[arg(long)] @@ -113,6 +113,35 @@ enum Commands { #[arg(long)] check: bool, }, + /// Provider operations: show and set provider/base URL + Provider { + /// Show current provider configuration + #[arg(long)] + show: bool, + /// List available providers + #[arg(long)] + list: bool, + /// Run a preflight check + #[arg(long)] + check: bool, + /// Optional model to check + #[arg(long)] + model: Option, + /// Set provider: openai | ollama + #[arg(long)] + set: Option, + /// Set base URL (used for Ollama) + #[arg(long)] + base_url: Option, + /// Set default model for a specific command + #[arg(long)] + set_for_cmd: Option, + /// Positional model argument when using --set-for-cmd + cmd_model: Option, + /// Interactive picker to choose provider and default models + #[arg(long)] + pick: bool, + }, /// Open a tiny native window to view the Qernel Zoo or a URL (macOS support today) See { /// Open a specific URL (defaults to the Qernel Zoo) @@ -134,6 +163,9 @@ fn main() -> Result<()> { Commands::Explain { files, per, model, markdown, output, no_pager, max_chars, check } => { if check { cmd::explain::check_explain(files, None) } else { cmd::explain::handle_explain(files, per, Some(model), markdown, output, !no_pager, max_chars) } } + Commands::Provider { show, list, set, base_url, check, model, set_for_cmd, cmd_model, pick } => { + cmd::provider::handle_provider(cmd::provider::ProviderCmd { show, list, set, base_url, check, model, set_for_cmd, cmd_model, pick }) + } Commands::See { url } => cmd::see::handle_see(url), } } \ No newline at end of file diff --git a/src/util.rs b/src/util.rs index 67772bb..3ab329c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -72,6 +72,19 @@ pub fn get_openai_api_key_from_env_or_config() -> Option { } None } +/// Resolve a Qernel personal access token from env or stored config +pub fn get_qernel_pat_from_env_or_config() -> Option { + if let Ok(t) = std::env::var("QERNEL_TOKEN") { + let t = t.trim().to_string(); + if !t.is_empty() { return Some(t); } + } + if let Ok(cfg) = load_config() { + if let Some(t) = cfg.token.as_ref() { + if !t.trim().is_empty() { return Some(t.trim().to_string()); } + } + } + None +} // Ensure the current process has OPENAI_API_KEY set. Returns true if set via config. // Note: In Rust 2024, mutating process env at runtime is unsafe; callers should @@ -96,7 +109,7 @@ pub fn get_default_prototype_model() -> String { .ok() .and_then(|c| c.default_prototype_model) .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "gpt-5-codex".to_string()) + .unwrap_or_else(|| "qernel-auto".to_string()) } /// Resolve default explain model from persisted config or fall back. @@ -105,7 +118,7 @@ pub fn get_default_explain_model() -> String { .ok() .and_then(|c| c.default_explain_model) .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "codex-mini-latest".to_string()) + .unwrap_or_else(|| "qernel-auto".to_string()) }