diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 5d942777d5..0b08e9fa27 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -56,6 +56,12 @@ OPENROUTER_API_KEY=sk-or-v1-... \ OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 \ ./target/release/buzz-agent +# Or Venice AI +BUZZ_AGENT_PROVIDER=venice \ +VENICE_API_KEY=vapi-... \ +VENICE_MODEL=zai-org-glm-5 \ + ./target/release/buzz-agent + # Or Databricks model serving via OAuth 2.0 PKCE BUZZ_AGENT_PROVIDER=databricks \ DATABRICKS_HOST=https://dbc-...cloud.databricks.com \ @@ -135,7 +141,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Variable | Default | Notes | |---|---|---| -| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | +| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `venice`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | | `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. | | `ANTHROPIC_MODEL` | — | Required when provider=anthropic. | | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | | @@ -147,6 +153,9 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `OPENROUTER_API_KEY` | — | Required when provider=openrouter. | | `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4.5`. | | `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | | +| `VENICE_API_KEY` | — | Required when provider=venice. | +| `VENICE_MODEL` | — | Required when provider=venice. | +| `VENICE_BASE_URL` | `https://api.venice.ai/api/v1` | Venice uses Chat Completions; Buzz disables Venice's additional system prompt. | | `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. | @@ -239,15 +248,18 @@ lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). | Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder | | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | | OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) | +| Venice AI | `venice` | `POST {base}/chat/completions` | tools-capable text models advertised by Venice's model catalog | | Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet | | Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 | -If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. +If a keyed provider is selected without its matching credential (`ANTHROPIC_API_KEY`, `OPENAI_COMPAT_API_KEY`, `OPENROUTER_API_KEY`, or `VENICE_API_KEY`), the agent returns an error — there is no implicit fallback to another provider. `provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format). By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway). +`provider=venice` always uses Venice's stable Chat Completions API. Buzz sends `venice_parameters.include_venice_system_prompt=false` so the configured agent prompt remains authoritative, and preserves Venice `reasoning_details` across tool-call rounds. + `provider=openrouter` is first-class, not routed through `provider=openai`: it speaks OpenAI's Chat Completions wire format but with OpenRouter-specific extensions layered on top — - `reasoning.effort` is set on the request when reasoning effort is configured. The request deliberately carries no `provider.require_parameters` filter: that filter routes only to endpoints advertising every parameter in the body, and 83 of 274 tools-capable OpenRouter models do not advertise `reasoning`, so it turns an effort setting into a hard 404 on a valid model id. A model that cannot reason answers without reasoning instead. diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index afbda5379d..8bd94f5808 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -664,6 +664,8 @@ const DEFAULT_SYSTEM_PROMPT: &str = pub enum Provider { Anthropic, OpenAi, + /// Venice AI. Uses the OpenAI-compatible Chat Completions wire format. + Venice, /// Databricks model serving. Routes to `{base_url}/serving-endpoints/{model}/invocations` /// with a dynamically-acquired bearer (OAuth 2.0 PKCE, or static `DATABRICKS_TOKEN`). /// Wire format is OpenAI-chat-compatible — reuses the same body builder and parser. @@ -769,6 +771,7 @@ impl Config { env("ANTHROPIC_API_KEY").as_deref(), env("OPENAI_COMPAT_API_KEY").as_deref(), env("OPENROUTER_API_KEY").as_deref(), + env("VENICE_API_KEY").as_deref(), )?; // Universal model override — takes priority over provider-specific model @@ -804,6 +807,13 @@ impl Config { env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, ), + Provider::Venice => ( + req("VENICE_API_KEY")?, + resolve_model(buzz_agent_model.as_deref(), env("VENICE_MODEL").as_deref()) + .ok_or_else(|| "config: VENICE_MODEL required".to_string())?, + env_or("VENICE_BASE_URL", "https://api.venice.ai/api/v1"), + OpenAiApi::Chat, + ), Provider::Databricks | Provider::DatabricksV2 => ( env("DATABRICKS_TOKEN").unwrap_or_default(), resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref()) @@ -1029,6 +1039,7 @@ fn resolve_provider( anthropic_key: Option<&str>, openai_key: Option<&str>, openrouter_key: Option<&str>, + venice_key: Option<&str>, ) -> Result { match requested.map(str::trim).filter(|s| !s.is_empty()) { Some(raw) => { @@ -1046,6 +1057,8 @@ fn resolve_provider( "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), "openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter), "openrouter" => Err("config: OPENROUTER_API_KEY required".into()), + "venice" if present_nonempty(venice_key) => Ok(Provider::Venice), + "venice" => Err("config: VENICE_API_KEY required".into()), _ => Err(format!( "config: BUZZ_AGENT_PROVIDER={raw} not supported" )), @@ -1263,11 +1276,11 @@ mod tests { #[test] fn resolve_provider_keeps_requested_provider_when_token_present() { assert_eq!( - resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(), + resolve_provider(Some("anthropic"), Some("sk-ant"), None, None, None).unwrap(), Provider::Anthropic ); assert_eq!( - resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(), + resolve_provider(Some("openai"), None, Some("sk-openai"), None, None).unwrap(), Provider::OpenAi ); } @@ -1275,17 +1288,18 @@ mod tests { #[test] fn resolve_provider_errors_when_requested_provider_key_missing() { // No fallback — missing key returns an error regardless of Databricks availability. - let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err(); + let err = resolve_provider(Some("anthropic"), None, None, None, None).unwrap_err(); assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}"); - let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err(); + let err = + resolve_provider(Some("openai-compat"), None, Some(" "), None, None).unwrap_err(); assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); } #[test] fn resolve_provider_errors_when_provider_env_absent() { // No implicit inference — absent BUZZ_AGENT_PROVIDER is an error. - let err = resolve_provider(None, None, None, None).unwrap_err(); + let err = resolve_provider(None, None, None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); } @@ -1295,19 +1309,19 @@ mod tests { // When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless // of DATABRICKS_HOST/MODEL (those are validated later in from_env()). assert_eq!( - resolve_provider(Some("databricks"), None, None, None).unwrap(), + resolve_provider(Some("databricks"), None, None, None, None).unwrap(), Provider::Databricks ); // Missing key for other providers still errors — no Databricks fallback. - let err = resolve_provider(Some("openai"), None, None, None).unwrap_err(); + let err = resolve_provider(Some("openai"), None, None, None, None).unwrap_err(); assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); - let err = resolve_provider(None, None, None, None).unwrap_err(); + let err = resolve_provider(None, None, None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); } #[test] fn resolve_provider_unsupported_error_preserves_user_casing() { - let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err(); + let err = resolve_provider(Some("OpenAIish"), None, None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish")); } @@ -2753,14 +2767,24 @@ mod tests { #[test] fn resolve_provider_openrouter_with_key() { assert_eq!( - resolve_provider(Some("openrouter"), None, None, Some("sk-or-123")).unwrap(), + resolve_provider(Some("openrouter"), None, None, Some("sk-or-123"), None,).unwrap(), Provider::OpenRouter ); } #[test] fn resolve_provider_openrouter_missing_key() { - let err = resolve_provider(Some("openrouter"), None, None, None).unwrap_err(); + let err = resolve_provider(Some("openrouter"), None, None, None, None).unwrap_err(); assert!(err.contains("OPENROUTER_API_KEY")); } + + #[test] + fn resolve_provider_venice_requires_its_own_key() { + assert_eq!( + resolve_provider(Some("venice"), None, None, None, Some("vapi-key")).unwrap(), + Provider::Venice + ); + let err = resolve_provider(Some("venice"), None, None, None, None).unwrap_err(); + assert!(err.contains("VENICE_API_KEY")); + } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 99e74c6d67..781b23f8f4 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -158,7 +158,7 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; parse_openai_with_reasoning_details(v) } - Provider::OpenAi | Provider::Databricks => { + Provider::OpenAi | Provider::Venice | Provider::Databricks => { self.openai_request( cfg, effective_model, @@ -183,9 +183,14 @@ impl Llm { parse_responses as OpenAiParse, ) } else { + let parse = if cfg.provider == Provider::Venice { + parse_openai_with_reasoning_details as OpenAiParse + } else { + parse_openai as OpenAiParse + }; ( openai_body(cfg, system_prompt, history, tools, request_model, e), - parse_openai as OpenAiParse, + parse, ) } }, @@ -270,7 +275,7 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; Ok(parse_openai(v)?.text) } - Provider::OpenAi | Provider::Databricks => { + Provider::OpenAi | Provider::Venice | Provider::Databricks => { let r = self .openai_request( cfg, @@ -288,18 +293,19 @@ impl Llm { parse_responses as OpenAiParse, ) } else { - ( - json!({ - "model": request_model, - "stream": false, - "max_completion_tokens": max_output_tokens, - "messages": [ - { "role": "system", "content": system_prompt }, - { "role": "user", "content": user_prompt }, - ], - }), - parse_openai as OpenAiParse, - ) + let mut body = json!({ + "model": request_model, + "stream": false, + "max_completion_tokens": max_output_tokens, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + }); + if cfg.provider == Provider::Venice { + apply_venice_mutations(&mut body); + } + (body, parse_openai as OpenAiParse) } }, ) @@ -862,6 +868,12 @@ fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec { .collect() } +fn apply_venice_mutations(body: &mut Value) { + body["venice_parameters"] = json!({ + "include_venice_system_prompt": false, + }); +} + fn openai_body( cfg: &Config, system_prompt: &str, @@ -951,6 +963,9 @@ fn openai_body( .collect(); let mut body = json!({ "model": effective_model, "stream": false, "max_completion_tokens": cfg.max_output_tokens, "messages": messages }); + if cfg.provider == Provider::Venice { + apply_venice_mutations(&mut body); + } if let Some(e) = effort { body["reasoning_effort"] = json!(e.openai_effort_str()); } @@ -1948,7 +1963,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { /// flow; subsequent requests use the cache + refresh transparently. pub(crate) fn build_token_source(cfg: &Config) -> Result, AgentError> { match cfg.provider { - Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => { + Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter | Provider::Venice => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } Provider::Databricks | Provider::DatabricksV2 => { @@ -3757,6 +3772,22 @@ mod tests { ); } + #[test] + fn venice_body_disables_provider_system_prompt() { + let body = openai_body( + &cfg(Provider::Venice), + "system", + &[HistoryItem::User("hi".into())], + &[], + "zai-org-glm-5", + None, + ); + assert_eq!( + body["venice_parameters"]["include_venice_system_prompt"], + false + ); + } + #[test] fn openai_body_emits_reasoning_effort_medium() { let body = openai_body( diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index 4253ef329c..9a603b0b76 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -173,12 +173,40 @@ struct Harness { impl Harness { async fn spawn(base_url: &str) -> Self { + Self::spawn_for_provider( + base_url, + "openai", + "OPENAI_COMPAT_API_KEY", + "OPENAI_COMPAT_MODEL", + "OPENAI_COMPAT_BASE_URL", + ) + .await + } + + async fn spawn_venice(base_url: &str) -> Self { + Self::spawn_for_provider( + base_url, + "venice", + "VENICE_API_KEY", + "VENICE_MODEL", + "VENICE_BASE_URL", + ) + .await + } + + async fn spawn_for_provider( + base_url: &str, + provider: &str, + api_key_env: &str, + model_env: &str, + base_url_env: &str, + ) -> Self { let bin = env!("CARGO_BIN_EXE_buzz-agent"); let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", base_url) + cmd.env("BUZZ_AGENT_PROVIDER", provider) + .env(api_key_env, "test") + .env(model_env, "fake-model") + .env(base_url_env, base_url) .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_MAX_ROUNDS", "4") @@ -303,6 +331,34 @@ async fn text_only_end_turn() { h.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn venice_uses_chat_completions_body_and_disables_provider_prompt() { + let (url, captures) = spawn_capturing_fake_llm(vec![openai_text("done")]).await; + let mut h = Harness::spawn_venice(&url).await; + let sid = init_session(&mut h).await; + let prompt_id = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "hi" }], + }), + ) + .await; + let response = h.recv_until(|value| value["id"] == prompt_id).await; + assert_eq!(response["result"]["stopReason"], "end_turn"); + + let requests = captures.lock().await; + assert_eq!(requests.len(), 1); + assert!(requests[0]["messages"].is_array()); + assert_eq!( + requests[0]["venice_parameters"]["include_venice_system_prompt"], + false + ); + drop(requests); + h.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_then_end_turn() { // Round 1: tool call (will fail with "unknown tool" since no MCP registered). diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..954f0a236a 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -110,6 +110,17 @@ pub async fn get_agent_models( return Ok(models); } + if let Some(models) = discover_venice_models( + &state.http_client, + &effective_provider, + &merged_env, + persisted_model.clone(), + ) + .await? + { + return Ok(models); + } + if let Some(models) = discover_openai_compatible_models( &state.http_client, &effective_provider, @@ -290,6 +301,12 @@ pub async fn discover_agent_models( return Ok(models); } + if let Some(models) = + discover_venice_models(&state.http_client, &effective_provider, &merged_env, None).await? + { + return Ok(models); + } + if let Some(models) = discover_openai_compatible_models( &state.http_client, &effective_provider, @@ -323,18 +340,6 @@ pub async fn discover_agent_models( run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await } -#[derive(Debug, Deserialize)] -struct OpenAiModelListResponse { - data: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpenAiModelListItem { - id: String, - #[serde(default)] - created: Option, -} - #[path = "agent_models_openrouter.rs"] mod openrouter; use openrouter::discover_openrouter_models; @@ -344,202 +349,18 @@ use openrouter::{ OpenRouterModelListItem, OpenRouterModelListResponse, }; -fn is_openai_compatible_provider(provider: Option<&str>) -> bool { - matches!( - provider - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("openai" | "openai-compat") - ) -} - +#[path = "agent_models_openai.rs"] +mod openai; +use openai::discover_openai_compatible_models; #[cfg(test)] -fn openai_compatible_models_url(env: &BTreeMap) -> String { - let base_url = env_value(env, "OPENAI_COMPAT_BASE_URL") - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); - format!("{}/models", base_url.trim_end_matches('/')) -} - -fn openai_compatible_models_url_for_discovery(env: &BTreeMap) -> String { - let base_url = env_or_process_value(env, "OPENAI_COMPAT_BASE_URL") - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); - format!("{}/models", base_url.trim_end_matches('/')) -} - -fn is_agent_text_model_id(id: &str) -> bool { - let lower = id.to_ascii_lowercase(); - if [ - "audio", - "dall-e", - "embedding", - "image", - "moderation", - "realtime", - "speech", - "transcribe", - "tts", - "whisper", - ] - .iter() - .any(|needle| lower.contains(needle)) - { - return false; - } - - lower.starts_with("gpt-") || lower.starts_with('o') || lower.starts_with("chatgpt-") -} - -fn openai_dated_snapshot_alias(id: &str) -> Option { - let (base, date) = id.rsplit_once('-')?; - if date.len() != 2 || !date.chars().all(|character| character.is_ascii_digit()) { - return None; - } - let (base, month) = base.rsplit_once('-')?; - if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) { - return None; - } - let (base, year) = base.rsplit_once('-')?; - if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) { - return None; - } - - Some(base.to_string()) -} - -fn openai_model_display_name(id: &str) -> String { - let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string()); - if let Some(rest) = canonical.strip_prefix("chatgpt-") { - return format!("ChatGPT {}", title_case_model_suffix(rest)); - } - if let Some(rest) = canonical.strip_prefix("gpt-") { - return format!("GPT-{}", title_case_model_suffix(rest)); - } - - canonical -} - -fn title_case_model_suffix(value: &str) -> String { - value - .split('-') - .enumerate() - .map(|(index, part)| { - let part = if part.eq_ignore_ascii_case("pro") { - "Pro".to_string() - } else if part.eq_ignore_ascii_case("mini") { - "mini".to_string() - } else if part.eq_ignore_ascii_case("nano") { - "nano".to_string() - } else { - part.to_string() - }; - - if index == 0 { - part - } else { - format!(" {part}") - } - }) - .collect::() -} - -fn normalize_openai_compatible_models( - response: OpenAiModelListResponse, - provider: Option<&str>, -) -> Vec { - let mut seen = HashSet::new(); - let mut items = response.data; - let filter_to_openai_text_models = matches!( - provider - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("openai") - ); - let all_ids = items - .iter() - .map(|item| item.id.clone()) - .collect::>(); - items.sort_by(|left, right| { - right - .created - .cmp(&left.created) - .then_with(|| left.id.cmp(&right.id)) - }); - - items - .into_iter() - .filter(|item| !filter_to_openai_text_models || is_agent_text_model_id(&item.id)) - .filter(|item| match openai_dated_snapshot_alias(&item.id) { - Some(alias) if filter_to_openai_text_models => !all_ids.contains(&alias), - Some(_) | None => true, - }) - .filter(|item| seen.insert(item.id.clone())) - .map(|item| AgentModelInfo { - name: Some(openai_model_display_name(&item.id)), - id: item.id, - description: None, - }) - .collect() -} - -async fn discover_openai_compatible_models( - client: &reqwest::Client, - provider: &DiscoveryProvider, - env: &BTreeMap, - selected_model: Option, -) -> Result, String> { - let relay_mesh = - provider.as_deref().map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); - if !relay_mesh && !is_openai_compatible_provider(provider.as_deref()) { - return Ok(None); - } - - let api_key = if relay_mesh { - crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string() - } else { - match provider.required_env(env, "OPENAI_COMPAT_API_KEY")? { - Some(api_key) => api_key, - None => return Ok(None), - } - }; - let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key); - let url = if relay_mesh { - format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL) - } else { - openai_compatible_models_url_for_discovery(env) - }; - let response = client - .get(&url) - .bearer_auth(&api_key) - .send() - .await - .map_err(|error| format!("OpenAI model discovery request failed: {error}"))?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - let body = crate::managed_agents::redact_env_values_in(&body, &redaction_env); - return Err(format!("OpenAI model discovery HTTP {status}: {body}")); - } - - let response = response - .json::() - .await - .map_err(|error| format!("OpenAI model discovery response parse failed: {error}"))?; - let models = normalize_openai_compatible_models(response, provider.as_deref()); - if models.is_empty() { - return Err("OpenAI model discovery returned no compatible text models".to_string()); - } +use openai::{ + is_openai_compatible_provider, normalize_openai_compatible_models, + openai_compatible_models_url, OpenAiModelListItem, OpenAiModelListResponse, +}; - Ok(Some(AgentModelsResponse { - agent_name: provider.as_deref().unwrap_or("openai").trim().to_string(), - agent_version: "models-api".to_string(), - models, - agent_default_model: None, - selected_model, - supports_switching: true, - })) -} +#[path = "agent_models_venice.rs"] +mod venice; +use venice::discover_venice_models; #[derive(Debug, Deserialize)] struct AnthropicModelListResponse { diff --git a/desktop/src-tauri/src/commands/agent_models_openai.rs b/desktop/src-tauri/src/commands/agent_models_openai.rs new file mode 100644 index 0000000000..8b9f12e091 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_openai.rs @@ -0,0 +1,218 @@ +use std::collections::{BTreeMap, HashSet}; + +use serde::Deserialize; + +use crate::managed_agents::{AgentModelInfo, AgentModelsResponse}; + +#[cfg(test)] +use super::env_value; +use super::{env_or_process_value, redaction_env_with_value, DiscoveryProvider}; + +#[derive(Debug, Deserialize)] +pub(super) struct OpenAiModelListResponse { + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct OpenAiModelListItem { + pub id: String, + #[serde(default)] + pub created: Option, +} + +pub(super) fn is_openai_compatible_provider(provider: Option<&str>) -> bool { + matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("openai" | "openai-compat") + ) +} + +#[cfg(test)] +pub(super) fn openai_compatible_models_url(env: &BTreeMap) -> String { + let base_url = env_value(env, "OPENAI_COMPAT_BASE_URL") + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + format!("{}/models", base_url.trim_end_matches('/')) +} + +fn openai_compatible_models_url_for_discovery(env: &BTreeMap) -> String { + let base_url = env_or_process_value(env, "OPENAI_COMPAT_BASE_URL") + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + format!("{}/models", base_url.trim_end_matches('/')) +} + +fn is_agent_text_model_id(id: &str) -> bool { + let lower = id.to_ascii_lowercase(); + if [ + "audio", + "dall-e", + "embedding", + "image", + "moderation", + "realtime", + "speech", + "transcribe", + "tts", + "whisper", + ] + .iter() + .any(|needle| lower.contains(needle)) + { + return false; + } + + lower.starts_with("gpt-") || lower.starts_with('o') || lower.starts_with("chatgpt-") +} + +fn openai_dated_snapshot_alias(id: &str) -> Option { + let (base, date) = id.rsplit_once('-')?; + if date.len() != 2 || !date.chars().all(|character| character.is_ascii_digit()) { + return None; + } + let (base, month) = base.rsplit_once('-')?; + if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) { + return None; + } + let (base, year) = base.rsplit_once('-')?; + if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) { + return None; + } + + Some(base.to_string()) +} + +fn openai_model_display_name(id: &str) -> String { + let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string()); + if let Some(rest) = canonical.strip_prefix("chatgpt-") { + return format!("ChatGPT {}", title_case_model_suffix(rest)); + } + if let Some(rest) = canonical.strip_prefix("gpt-") { + return format!("GPT-{}", title_case_model_suffix(rest)); + } + + canonical +} + +fn title_case_model_suffix(value: &str) -> String { + value + .split('-') + .enumerate() + .map(|(index, part)| { + let part = if part.eq_ignore_ascii_case("pro") { + "Pro".to_string() + } else if part.eq_ignore_ascii_case("mini") { + "mini".to_string() + } else if part.eq_ignore_ascii_case("nano") { + "nano".to_string() + } else { + part.to_string() + }; + + if index == 0 { + part + } else { + format!(" {part}") + } + }) + .collect::() +} + +pub(super) fn normalize_openai_compatible_models( + response: OpenAiModelListResponse, + provider: Option<&str>, +) -> Vec { + let mut seen = HashSet::new(); + let mut items = response.data; + let filter_to_openai_text_models = matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("openai") + ); + let all_ids = items + .iter() + .map(|item| item.id.clone()) + .collect::>(); + items.sort_by(|left, right| { + right + .created + .cmp(&left.created) + .then_with(|| left.id.cmp(&right.id)) + }); + + items + .into_iter() + .filter(|item| !filter_to_openai_text_models || is_agent_text_model_id(&item.id)) + .filter(|item| match openai_dated_snapshot_alias(&item.id) { + Some(alias) if filter_to_openai_text_models => !all_ids.contains(&alias), + Some(_) | None => true, + }) + .filter(|item| seen.insert(item.id.clone())) + .map(|item| AgentModelInfo { + name: Some(openai_model_display_name(&item.id)), + id: item.id, + description: None, + }) + .collect() +} + +pub(super) async fn discover_openai_compatible_models( + client: &reqwest::Client, + provider: &DiscoveryProvider, + env: &BTreeMap, + selected_model: Option, +) -> Result, String> { + let relay_mesh = + provider.as_deref().map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); + if !relay_mesh && !is_openai_compatible_provider(provider.as_deref()) { + return Ok(None); + } + + let api_key = if relay_mesh { + crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string() + } else { + match provider.required_env(env, "OPENAI_COMPAT_API_KEY")? { + Some(api_key) => api_key, + None => return Ok(None), + } + }; + let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key); + let url = if relay_mesh { + format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL) + } else { + openai_compatible_models_url_for_discovery(env) + }; + let response = client + .get(&url) + .bearer_auth(&api_key) + .send() + .await + .map_err(|error| format!("OpenAI model discovery request failed: {error}"))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let body = crate::managed_agents::redact_env_values_in(&body, &redaction_env); + return Err(format!("OpenAI model discovery HTTP {status}: {body}")); + } + + let response = response + .json::() + .await + .map_err(|error| format!("OpenAI model discovery response parse failed: {error}"))?; + let models = normalize_openai_compatible_models(response, provider.as_deref()); + if models.is_empty() { + return Err("OpenAI model discovery returned no compatible text models".to_string()); + } + + Ok(Some(AgentModelsResponse { + agent_name: provider.as_deref().unwrap_or("openai").trim().to_string(), + agent_version: "models-api".to_string(), + models, + agent_default_model: None, + selected_model, + supports_switching: true, + })) +} diff --git a/desktop/src-tauri/src/commands/agent_models_venice.rs b/desktop/src-tauri/src/commands/agent_models_venice.rs new file mode 100644 index 0000000000..a556a27e85 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_venice.rs @@ -0,0 +1,214 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::managed_agents::{AgentModelInfo, AgentModelsResponse}; + +#[cfg(test)] +use super::env_value; +use super::{env_or_process_value, redaction_env_with_value, DiscoveryProvider}; + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(Clone))] +pub(super) struct VeniceModelListResponse { + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(Clone))] +pub(super) struct VeniceModelListItem { + pub id: String, + pub model_spec: VeniceModelSpec, +} + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(Clone))] +pub(super) struct VeniceModelSpec { + #[serde(default)] + pub capabilities: Option, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(Clone))] +pub(super) struct VeniceModelCapabilities { + #[serde(default)] + pub supports_function_calling: bool, +} + +pub(super) fn is_venice_provider(provider: Option<&str>) -> bool { + matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("venice") + ) +} + +#[cfg(test)] +pub(super) fn venice_models_url(env: &BTreeMap) -> String { + let base_url = env_value(env, "VENICE_BASE_URL") + .unwrap_or_else(|| "https://api.venice.ai/api/v1".to_string()); + format!("{}/models", base_url.trim_end_matches('/')) +} + +fn venice_models_url_for_discovery(env: &BTreeMap) -> String { + let base_url = env_or_process_value(env, "VENICE_BASE_URL") + .unwrap_or_else(|| "https://api.venice.ai/api/v1".to_string()); + format!("{}/models", base_url.trim_end_matches('/')) +} + +pub(super) async fn discover_venice_models( + client: &reqwest::Client, + provider: &DiscoveryProvider, + env: &BTreeMap, + selected_model: Option, +) -> Result, String> { + if !is_venice_provider(provider.as_deref()) { + return Ok(None); + } + + let api_key = match provider.required_env(env, "VENICE_API_KEY")? { + Some(api_key) => api_key, + None => return Ok(None), + }; + let redaction_env = redaction_env_with_value(env, "VENICE_API_KEY", &api_key); + let url = venice_models_url_for_discovery(env); + let response = client + .get(&url) + .query(&[("type", "text")]) + .bearer_auth(&api_key) + .send() + .await + .map_err(|error| format!("Venice model discovery request failed: {error}"))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let body = crate::managed_agents::redact_env_values_in(&body, &redaction_env); + return Err(format!("Venice model discovery HTTP {status}: {body}")); + } + + let response = response + .json::() + .await + .map_err(|error| format!("Venice model discovery response parse failed: {error}"))?; + filter_venice_models(response, selected_model) +} + +pub(super) fn filter_venice_models( + response: VeniceModelListResponse, + selected_model: Option, +) -> Result, String> { + let models = response + .data + .into_iter() + .filter(|model| { + model + .model_spec + .capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.supports_function_calling) + }) + .map(|model| AgentModelInfo { + name: Some(model.id.clone()), + id: model.id, + description: model.model_spec.description, + }) + .collect::>(); + + if models.is_empty() { + return Err("Venice model discovery returned no tools-capable models".to_string()); + } + + Ok(Some(AgentModelsResponse { + agent_name: "venice".to_string(), + agent_version: "models-api".to_string(), + models, + agent_default_model: None, + selected_model, + supports_switching: true, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_matches_case_insensitively() { + assert!(is_venice_provider(Some("venice"))); + assert!(is_venice_provider(Some(" Venice "))); + assert!(!is_venice_provider(Some("openai-compat"))); + assert!(!is_venice_provider(None)); + } + + #[test] + fn models_url_uses_default_and_custom_base_urls() { + assert_eq!( + venice_models_url(&BTreeMap::new()), + "https://api.venice.ai/api/v1/models" + ); + let env = BTreeMap::from([( + "VENICE_BASE_URL".to_string(), + "https://venice-proxy.example/v1/".to_string(), + )]); + assert_eq!( + venice_models_url(&env), + "https://venice-proxy.example/v1/models" + ); + } + + #[test] + fn filter_keeps_only_function_calling_models() { + let response = VeniceModelListResponse { + data: vec![ + VeniceModelListItem { + id: "zai-org-glm-5".to_string(), + model_spec: VeniceModelSpec { + capabilities: Some(VeniceModelCapabilities { + supports_function_calling: true, + }), + description: Some("Tools-capable model".to_string()), + }, + }, + VeniceModelListItem { + id: "venice-text-only".to_string(), + model_spec: VeniceModelSpec { + capabilities: Some(VeniceModelCapabilities { + supports_function_calling: false, + }), + description: None, + }, + }, + ], + }; + let result = filter_venice_models(response, Some("zai-org-glm-5".to_string())) + .expect("catalog should normalize") + .expect("Venice provider should return a catalog"); + assert_eq!(result.models.len(), 1); + assert_eq!(result.models[0].id, "zai-org-glm-5"); + assert_eq!( + result.models[0].description.as_deref(), + Some("Tools-capable model") + ); + assert_eq!(result.selected_model.as_deref(), Some("zai-org-glm-5")); + } + + #[test] + fn filter_rejects_catalog_without_function_calling_models() { + let response = VeniceModelListResponse { + data: vec![VeniceModelListItem { + id: "venice-text-only".to_string(), + model_spec: VeniceModelSpec { + capabilities: None, + description: None, + }, + }], + }; + let error = filter_venice_models(response, None).expect_err("catalog must be rejected"); + assert!(error.contains("no tools-capable models")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff1..4eb03ea400 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -53,6 +53,8 @@ use crate::managed_agents::{ mod cli_login; pub(crate) mod cli_probe; +#[path = "readiness_provider.rs"] +mod readiness_provider; // ── EffectiveAgentEnv ───────────────────────────────────────────────────────── @@ -388,6 +390,7 @@ impl AgentReadiness { /// provider-specific credentials are required: /// - `anthropic` → `ANTHROPIC_API_KEY` /// - `openai` → `OPENAI_COMPAT_API_KEY` +/// - `venice` → `VENICE_API_KEY` /// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional — /// OAuth PKCE is the fallback) /// * **claude**: a successful `claude auth status` probe. @@ -481,6 +484,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { } Some("anthropic") => Some("ANTHROPIC_MODEL"), Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), + Some("venice") => Some("VENICE_MODEL"), Some("openrouter") => Some("OPENROUTER_MODEL"), _ => None, }; @@ -503,36 +507,11 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { // A key present with an empty value is treated as absent — matching the // dialog's (envVars[key] ?? "").length === 0 emptiness check. let env_key_missing = |key: &str| effective.env.get(key).is_none_or(|v| v.is_empty()); - match provider { - Some("anthropic") - if env_key_missing("ANTHROPIC_API_KEY") => { - missing.push(Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string(), - }); - } - Some("openai") - if env_key_missing("OPENAI_COMPAT_API_KEY") => { - missing.push(Requirement::EnvKey { - key: "OPENAI_COMPAT_API_KEY".to_string(), - }); - } - Some("databricks") | Some("databricks_v2") | Some("databricks-v2") - // DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional - // (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143). - if env_key_missing("DATABRICKS_HOST") => { - missing.push(Requirement::EnvKey { - key: "DATABRICKS_HOST".to_string(), - }); - } - Some("openrouter") - if env_key_missing("OPENROUTER_API_KEY") => { - missing.push(Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string(), - }); - } - _ => { - // Unknown provider or no provider yet — only the NormalizedField - // requirement above captures this gap. + if let Some(key) = readiness_provider::credential_env_key(provider) { + if env_key_missing(key) { + missing.push(Requirement::EnvKey { + key: key.to_string(), + }); } } @@ -614,37 +593,12 @@ fn goose_requirements( .map(|c| c.extra.get(key).is_some_and(|v| !v.is_empty())) .unwrap_or(false) }; - match effective_provider { - Some("anthropic") - if env_key_missing("ANTHROPIC_API_KEY") && !file_key_present("ANTHROPIC_API_KEY") => - { - missing.push(Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string(), - }); - } - Some("openai") - if env_key_missing("OPENAI_COMPAT_API_KEY") - && !file_key_present("OPENAI_COMPAT_API_KEY") => - { + if let Some(key) = readiness_provider::credential_env_key(effective_provider) { + if env_key_missing(key) && !file_key_present(key) { missing.push(Requirement::EnvKey { - key: "OPENAI_COMPAT_API_KEY".to_string(), + key: key.to_string(), }); } - Some("databricks") | Some("databricks_v2") | Some("databricks-v2") - if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") => - { - missing.push(Requirement::EnvKey { - key: "DATABRICKS_HOST".to_string(), - }); - } - Some("openrouter") - if env_key_missing("OPENROUTER_API_KEY") && !file_key_present("OPENROUTER_API_KEY") => - { - missing.push(Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string(), - }); - } - _ => {} } missing @@ -761,6 +715,32 @@ mod tests { assert!(agent_readiness(&env).is_ready()); } + #[test] + fn buzz_agent_venice_requires_key_and_accepts_provider_model_fallback() { + let missing_key = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "venice"), + ("VENICE_MODEL", "zai-org-glm-5"), + ]), + ); + assert!(agent_readiness(&missing_key) + .requirements() + .contains(&Requirement::EnvKey { + key: "VENICE_API_KEY".to_string() + })); + + let ready = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "venice"), + ("VENICE_MODEL", "zai-org-glm-5"), + ("VENICE_API_KEY", "vapi-test"), + ]), + ); + assert!(agent_readiness(&ready).is_ready()); + } + #[test] fn buzz_agent_databricks_with_host_and_model_is_ready_without_token() { // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. @@ -852,6 +832,19 @@ mod tests { assert!(agent_readiness(&env).is_ready()); } + #[test] + fn goose_venice_with_model_and_key_is_ready() { + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "venice"), + ("GOOSE_MODEL", "zai-org-glm-5"), + ("VENICE_API_KEY", "vapi-test"), + ]), + ); + assert!(goose_requirements(&env, None).is_empty()); + } + // ── empty-string semantics ──────────────────────────────────────────── // // A key present with an empty value ("") must be treated as MISSING, to diff --git a/desktop/src-tauri/src/managed_agents/readiness_provider.rs b/desktop/src-tauri/src/managed_agents/readiness_provider.rs new file mode 100644 index 0000000000..1c4b44d7bc --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_provider.rs @@ -0,0 +1,12 @@ +pub(super) fn credential_env_key(provider: Option<&str>) -> Option<&'static str> { + match provider { + Some("anthropic") => Some("ANTHROPIC_API_KEY"), + Some("openai") => Some("OPENAI_COMPAT_API_KEY"), + Some("databricks") | Some("databricks_v2") | Some("databricks-v2") => { + Some("DATABRICKS_HOST") + } + Some("openrouter") => Some("OPENROUTER_API_KEY"), + Some("venice") => Some("VENICE_API_KEY"), + _ => None, + } +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index f2eb7f285c..22204ebc37 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -153,6 +153,12 @@ with a TypeScript lookup table or an id comparison in a component. themselves. Never synthesize a run location a surface doesn't have. Don't expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI copy. +12. **First-class LLM providers must be wired end to end.** A provider option + is incomplete unless its credential key is represented in + `PROVIDER_CREDENTIAL_CONFIG`, Rust readiness covers both Buzz Agent and + Goose where supported, and model discovery returns only models that can use + tools. Venice uses `VENICE_API_KEY` for both harnesses and filters its text + catalog on `model_spec.capabilities.supportsFunctionCalling`. ## The tests that enforce this diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index eb24d88084..7301a7ab4e 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -5,7 +5,9 @@ import { getDefaultPersonaRuntime, getPersonaModelOptions, getPersonaProviderOptions, + getProviderApiKeyEnvVar, getProviderApiKeyLabel, + requiredCredentialEnvKeys, resetConfigForHarnessChange, runtimeSupportsLlmProviderSelection, } from "./agentConfigOptions.tsx"; @@ -33,6 +35,14 @@ test("getPersonaProviderOptions returns databricks v1 and v2 when hideProviderId assert.ok(ids.includes("databricks_v2"), "databricks v2 present"); }); +test("getPersonaProviderOptions includes first-class Venice AI", () => { + const options = getPersonaProviderOptions("", "buzz-agent"); + assert.deepEqual( + options.find((option) => option.id === "venice"), + { id: "venice", label: "Venice AI" }, + ); +}); + test("getPersonaProviderOptions hides databricks v1 when it is in hideProviderIds", () => { const options = getPersonaProviderOptions( "", @@ -216,6 +226,14 @@ test("getPersonaModelOptions for buzz-agent with anthropic filters out zero-valu ); }); +test("getPersonaModelOptions requires an explicit Venice model", () => { + const options = getPersonaModelOptions("buzz-agent", "venice"); + assert.equal( + options.some((option) => option.id === ""), + false, + ); +}); + test("getPersonaModelOptions for buzz-agent with no provider returns default model", () => { const options = getPersonaModelOptions("buzz-agent", ""); assert.equal(options.length, 1); @@ -277,6 +295,17 @@ test("getProviderApiKeyLabel_openrouter_returns_openrouter_label", () => { assert.equal(getProviderApiKeyLabel("openrouter"), "OpenRouter API Key"); }); +test("Venice uses its dedicated API key for Buzz Agent and Goose", () => { + assert.equal(getProviderApiKeyLabel("venice"), "Venice AI API Key"); + assert.equal(getProviderApiKeyEnvVar("venice"), "VENICE_API_KEY"); + assert.deepEqual(requiredCredentialEnvKeys("buzz-agent", "venice"), [ + "VENICE_API_KEY", + ]); + assert.deepEqual(requiredCredentialEnvKeys("goose", "venice"), [ + "VENICE_API_KEY", + ]); +}); + test("getProviderApiKeyLabel_databricks_returns_null", () => { // Databricks uses OAuth PKCE — no typed-secret label. assert.equal(getProviderApiKeyLabel("databricks"), null); diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5c515a0507..dfda129ca0 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -49,6 +49,7 @@ const KNOWN_LLM_PROVIDER_IDS = [ "openai", "openai-compat", "openrouter", + "venice", ] as const; type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number]; @@ -135,6 +136,11 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< secretEnvVar: "OPENROUTER_API_KEY", apiKeyLabel: "OpenRouter API Key", }, + venice: { + requiredEnvKeys: ["VENICE_API_KEY"], + secretEnvVar: "VENICE_API_KEY", + apiKeyLabel: "Venice AI API Key", + }, }; const DEFAULT_MODEL_OPTION: PersonaModelOption = { @@ -147,6 +153,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ { id: "openai", label: "OpenAI" }, { id: "openai-compat", label: "OpenAI-compatible" }, { id: "openrouter", label: "OpenRouter" }, + { id: "venice", label: "Venice AI" }, { id: "relay-mesh", label: "Buzz shared compute" }, { id: "databricks", label: "Databricks" }, { id: "databricks_v2", label: "Databricks v2" }, @@ -307,7 +314,8 @@ export function providerRequiresExplicitModel( trimmedProvider === "anthropic" || trimmedProvider === "openai" || trimmedProvider === "openai-compat" || - trimmedProvider === "openrouter" + trimmedProvider === "openrouter" || + trimmedProvider === "venice" ); } diff --git a/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs b/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs index 6bce6a7a93..07d2494ea2 100644 --- a/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs +++ b/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs @@ -35,6 +35,15 @@ test("envVarsClearingManagedApiKey clears when leaving to a custom/empty provide assert.deepEqual(next, {}); }); +test("envVarsClearingManagedApiKey clears Venice credentials on provider switch", () => { + const next = envVarsClearingManagedApiKey( + { VENICE_API_KEY: "vapi-secret", KEEP: "x" }, + "venice", + "anthropic", + ); + assert.deepEqual(next, { KEEP: "x" }); +}); + test("envVarsClearingManagedApiKey is a no-op when the managed key is shared or absent", () => { const current = { ANTHROPIC_API_KEY: "sk-1" }; assert.equal( diff --git a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts index cb63361d51..a860d5ac70 100644 --- a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts +++ b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts @@ -76,6 +76,9 @@ test.describe("agent provider dropdown screenshots", () => { await expect( page.getByTestId("global-agent-provider-option-databricks_v2"), ).toHaveText("Databricks v2"); + await expect( + page.getByTestId("global-agent-provider-option-venice"), + ).toHaveText("Venice AI"); await page.keyboard.press("Escape"); await expect( page.getByTestId("global-agent-provider-option-databricks"),