mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: support keyless openai-compatible endpoints
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -752,6 +752,9 @@ const DEFAULT_SYSTEM_PROMPT: &str =
|
||||
pub enum Provider {
|
||||
Anthropic,
|
||||
OpenAi,
|
||||
/// A custom OpenAI-compatible endpoint. Unlike official OpenAI, the base
|
||||
/// URL is explicit and bearer authentication is optional.
|
||||
OpenAiCompat,
|
||||
/// 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.
|
||||
@@ -901,6 +904,16 @@ impl Config {
|
||||
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
|
||||
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
|
||||
),
|
||||
Provider::OpenAiCompat => (
|
||||
env("OPENAI_COMPAT_API_KEY").unwrap_or_default(),
|
||||
resolve_model(
|
||||
buzz_agent_model.as_deref(),
|
||||
env("OPENAI_COMPAT_MODEL").as_deref(),
|
||||
)
|
||||
.ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?,
|
||||
parse_openai_compat_base_url(env("OPENAI_COMPAT_BASE_URL").as_deref())?,
|
||||
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
|
||||
),
|
||||
Provider::Databricks | Provider::DatabricksV2 => (
|
||||
env("DATABRICKS_TOKEN").unwrap_or_default(),
|
||||
resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref())
|
||||
@@ -1139,10 +1152,9 @@ fn resolve_provider(
|
||||
"anthropic" => Err(
|
||||
"config: ANTHROPIC_API_KEY required".into(),
|
||||
),
|
||||
"openai" | "openai-compat" if present_nonempty(openai_key) => Ok(Provider::OpenAi),
|
||||
"openai" | "openai-compat" => Err(
|
||||
"config: OPENAI_COMPAT_API_KEY required".into(),
|
||||
),
|
||||
"openai" if present_nonempty(openai_key) => Ok(Provider::OpenAi),
|
||||
"openai" => Err("config: OPENAI_COMPAT_API_KEY required".into()),
|
||||
"openai-compat" => Ok(Provider::OpenAiCompat),
|
||||
"databricks" => Ok(Provider::Databricks),
|
||||
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
|
||||
"openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter),
|
||||
@@ -1158,6 +1170,19 @@ fn resolve_provider(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_openai_compat_base_url(raw: Option<&str>) -> Result<String, String> {
|
||||
let value = raw
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "config: OPENAI_COMPAT_BASE_URL required for openai-compat".to_string())?;
|
||||
let parsed = url::Url::parse(value)
|
||||
.map_err(|_| "config: OPENAI_COMPAT_BASE_URL must be a valid HTTP(S) URL".to_string())?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return Err("config: OPENAI_COMPAT_BASE_URL must be a valid HTTP(S) URL".to_string());
|
||||
}
|
||||
Ok(value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the
|
||||
/// caller hands in the raw value.
|
||||
fn parse_openai_api(raw: Option<&str>) -> Result<OpenAiApi, String> {
|
||||
@@ -1450,13 +1475,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_provider_errors_when_requested_provider_key_missing() {
|
||||
// No fallback — missing key returns an error regardless of Databricks availability.
|
||||
fn resolve_provider_requires_only_official_openai_key() {
|
||||
let err = resolve_provider(Some("anthropic"), 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"), None, Some(" "), None).unwrap_err();
|
||||
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
|
||||
|
||||
assert_eq!(
|
||||
resolve_provider(Some("openai-compat"), None, None, None).unwrap(),
|
||||
Provider::OpenAiCompat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_compat_base_url_is_required_and_normalized() {
|
||||
assert!(parse_openai_compat_base_url(None)
|
||||
.unwrap_err()
|
||||
.contains("required for openai-compat"));
|
||||
assert!(parse_openai_compat_base_url(Some("ftp://localhost/v1"))
|
||||
.unwrap_err()
|
||||
.contains("valid HTTP(S) URL"));
|
||||
assert_eq!(
|
||||
parse_openai_compat_base_url(Some(" http://localhost:11434/v1/// ")).unwrap(),
|
||||
"http://localhost:11434/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -106,7 +106,7 @@ impl Llm {
|
||||
.await
|
||||
.and_then(parse_openai_with_reasoning_details)
|
||||
}
|
||||
Provider::OpenAi | Provider::Databricks => {
|
||||
Provider::OpenAi | Provider::OpenAiCompat | Provider::Databricks => {
|
||||
self.openai_request(cfg, effective_model, |use_responses, request_model| {
|
||||
// Normalize effort for model-specific availability. Startup no longer rejects
|
||||
// `max` for pure OpenAI/Databricks; this per-model table is the single authority
|
||||
@@ -246,7 +246,7 @@ impl Llm {
|
||||
let v = self.post_openrouter(cfg, &body).await?;
|
||||
Ok(parse_openai(v)?.text)
|
||||
}
|
||||
Provider::OpenAi | Provider::Databricks => {
|
||||
Provider::OpenAi | Provider::OpenAiCompat | Provider::Databricks => {
|
||||
let r = self
|
||||
.openai_request(cfg, effective_model, |use_responses, request_model| {
|
||||
if use_responses {
|
||||
@@ -471,14 +471,19 @@ impl Llm {
|
||||
// statuses map to `LlmAuth` in `post`: a 403 is indistinguishable from
|
||||
// an expired-token 403 here, so we refresh once and let it propagate.
|
||||
let mut bearer = self.auth.bearer().await.map_err(PostError::from)?;
|
||||
let use_bearer = cfg.provider != Provider::OpenAiCompat || !bearer.is_empty();
|
||||
let mut refreshed = false;
|
||||
loop {
|
||||
match post(&self.http, &url, body_ref, cfg.llm_timeout, |r| {
|
||||
r.bearer_auth(&bearer)
|
||||
match post(&self.http, &url, body_ref, cfg.llm_timeout, |request| {
|
||||
if use_bearer {
|
||||
request.bearer_auth(&bearer)
|
||||
} else {
|
||||
request
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Err(PostError::Agent(AgentError::LlmAuth(_))) if !refreshed => {
|
||||
Err(PostError::Agent(AgentError::LlmAuth(_))) if use_bearer && !refreshed => {
|
||||
refreshed = true;
|
||||
bearer = self
|
||||
.auth
|
||||
@@ -2071,7 +2076,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<Arc<dyn TokenSource>, AgentError> {
|
||||
match cfg.provider {
|
||||
Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => {
|
||||
Provider::Anthropic | Provider::OpenAi | Provider::OpenAiCompat | Provider::OpenRouter => {
|
||||
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
|
||||
}
|
||||
Provider::Databricks | Provider::DatabricksV2 => {
|
||||
@@ -2097,9 +2102,11 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, A
|
||||
pub(crate) fn summary_completion_cap(provider: Provider, max_output_tokens: u32) -> u32 {
|
||||
match provider {
|
||||
Provider::OpenRouter => max_output_tokens.saturating_mul(2),
|
||||
Provider::Anthropic | Provider::OpenAi | Provider::Databricks | Provider::DatabricksV2 => {
|
||||
max_output_tokens
|
||||
}
|
||||
Provider::Anthropic
|
||||
| Provider::OpenAi
|
||||
| Provider::OpenAiCompat
|
||||
| Provider::Databricks
|
||||
| Provider::DatabricksV2 => max_output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5725,6 +5732,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_openai_compat_omits_authorization_when_key_is_empty() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let captured = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0u8; 4096];
|
||||
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let count = socket.read(&mut buffer).await.unwrap();
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
let body = "{\"ok\":true}";
|
||||
socket
|
||||
.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&bytes).to_ascii_lowercase()
|
||||
});
|
||||
|
||||
let llm = llm_with(Arc::new(StaticTokenSource::new("")));
|
||||
let mut config = cfg(Provider::OpenAiCompat);
|
||||
config.base_url = base;
|
||||
llm.post_openai(&config, "/v1/x", &json!({}), "model")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let headers = captured.await.unwrap();
|
||||
assert!(!headers.contains("authorization:"), "{headers}");
|
||||
}
|
||||
|
||||
/// A single 401 forces exactly one refresh, the retry with the fresh
|
||||
/// token succeeds, and a *later* call gets its own refresh — proving the
|
||||
/// one-shot guard is per-call, not stored on the source.
|
||||
|
||||
@@ -11,7 +11,8 @@ use super::managed_agent_definition::apply_model_provider_prompt_update;
|
||||
#[cfg(test)]
|
||||
use super::agent_models_env::env_value;
|
||||
use super::agent_models_env::{
|
||||
effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider,
|
||||
effective_discovery_provider, env_or_process_override, env_or_process_value,
|
||||
redaction_env_with_value, DiscoveryProvider,
|
||||
};
|
||||
use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback};
|
||||
|
||||
@@ -362,10 +363,24 @@ fn openai_compatible_models_url(env: &BTreeMap<String, String>) -> String {
|
||||
format!("{}/models", base_url.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
fn openai_compatible_models_url_for_discovery(env: &BTreeMap<String, String>) -> 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 openai_compatible_models_url_for_discovery(
|
||||
provider: Option<&str>,
|
||||
env: &BTreeMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let base_url = env_or_process_value(env, "OPENAI_COMPAT_BASE_URL");
|
||||
let base_url = if provider.map(str::trim) == Some("openai-compat") {
|
||||
base_url.ok_or_else(|| {
|
||||
"OPENAI_COMPAT_BASE_URL required for OpenAI-compatible model discovery".to_string()
|
||||
})?
|
||||
} else {
|
||||
base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string())
|
||||
};
|
||||
let parsed = url::Url::parse(base_url.trim())
|
||||
.map_err(|_| "OPENAI_COMPAT_BASE_URL must be a valid HTTP(S) URL".to_string())?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return Err("OPENAI_COMPAT_BASE_URL must be a valid HTTP(S) URL".to_string());
|
||||
}
|
||||
Ok(format!("{}/models", base_url.trim().trim_end_matches('/')))
|
||||
}
|
||||
|
||||
fn is_agent_text_model_id(id: &str) -> bool {
|
||||
@@ -496,8 +511,11 @@ async fn discover_openai_compatible_models(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let is_compat = provider.as_deref().map(str::trim) == Some("openai-compat");
|
||||
let api_key = if relay_mesh {
|
||||
crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string()
|
||||
} else if is_compat {
|
||||
env_or_process_override(env, "OPENAI_COMPAT_API_KEY").unwrap_or_default()
|
||||
} else {
|
||||
match provider.required_env(env, "OPENAI_COMPAT_API_KEY")? {
|
||||
Some(api_key) => api_key,
|
||||
@@ -508,11 +526,15 @@ async fn discover_openai_compatible_models(
|
||||
let url = if relay_mesh {
|
||||
format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL)
|
||||
} else {
|
||||
openai_compatible_models_url_for_discovery(env)
|
||||
openai_compatible_models_url_for_discovery(provider.as_deref(), env)?
|
||||
};
|
||||
let response = client
|
||||
.get(&url)
|
||||
.bearer_auth(&api_key)
|
||||
let request = client.get(&url);
|
||||
let request = if api_key.is_empty() {
|
||||
request
|
||||
} else {
|
||||
request.bearer_auth(&api_key)
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("OpenAI model discovery request failed: {error}"))?;
|
||||
@@ -673,7 +695,6 @@ async fn discover_anthropic_models(
|
||||
if models.is_empty() {
|
||||
return Err("Anthropic model discovery returned no models".to_string());
|
||||
}
|
||||
|
||||
Ok(Some(AgentModelsResponse {
|
||||
agent_name: provider
|
||||
.as_deref()
|
||||
@@ -687,7 +708,6 @@ async fn discover_anthropic_models(
|
||||
supports_switching: true,
|
||||
}))
|
||||
}
|
||||
|
||||
#[path = "agent_models_databricks.rs"]
|
||||
mod databricks;
|
||||
#[cfg(test)]
|
||||
@@ -696,7 +716,6 @@ use databricks::{
|
||||
should_start_interactive_auth,
|
||||
};
|
||||
use databricks::{discover_databricks_models, DatabricksAuthIntent};
|
||||
|
||||
/// Update mutable fields on an existing managed agent record.
|
||||
///
|
||||
/// Does NOT auto-restart the agent. Runtime config changes (system prompt,
|
||||
@@ -724,10 +743,8 @@ pub async fn update_managed_agent(
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_agent_session_caches(pubkey);
|
||||
}
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
|
||||
let previous_record = record.clone();
|
||||
|
||||
let mut name_changed = false;
|
||||
if let Some(name_update) = input.name {
|
||||
let trimmed = name_update.trim().to_string();
|
||||
@@ -785,7 +802,6 @@ pub async fn update_managed_agent(
|
||||
crate::managed_agents::validate_user_env_keys(&env_vars)?;
|
||||
record.env_vars = env_vars;
|
||||
}
|
||||
|
||||
// Native provider/model fields are authoritative. Keep the typed marker
|
||||
// derived for new records while retaining legacy typed records for
|
||||
// non-native providers.
|
||||
@@ -800,7 +816,6 @@ pub async fn update_managed_agent(
|
||||
record.model = Some(model_ref.clone());
|
||||
record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref });
|
||||
}
|
||||
|
||||
// Inbound author gate: merge patch onto current values, then validate
|
||||
// the merged state. This lets a single update switch to Allowlist AND
|
||||
// supply pubkeys atomically.
|
||||
@@ -823,21 +838,16 @@ pub async fn update_managed_agent(
|
||||
if input.respond_to_allowlist.is_some() {
|
||||
record.respond_to_allowlist = prospective_allowlist;
|
||||
}
|
||||
|
||||
record.updated_at = now_iso();
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|r| r.pubkey == input.pubkey)
|
||||
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;
|
||||
|
||||
// Publish the edit to the relay. After-save, inside the lock, before
|
||||
// any .await. The retention upsert hashes the opt-IN projection, so an
|
||||
// update that touched only runtime/local fields is a no-op publish.
|
||||
super::agents::retain_managed_agent_pending(&app, &state, record);
|
||||
|
||||
let sync_params = if name_changed {
|
||||
let agent_keys = Keys::parse(&record.private_key_nsec)
|
||||
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
|
||||
@@ -862,7 +872,6 @@ pub async fn update_managed_agent(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let summary = {
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
build_managed_agent_summary(
|
||||
@@ -876,9 +885,7 @@ pub async fn update_managed_agent(
|
||||
let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record));
|
||||
(summary, sync_params, rollback)
|
||||
}; // lock dropped here
|
||||
|
||||
try_regenerate_nest(&app);
|
||||
|
||||
// Phase 2: relay profile sync (async, outside lock). A rename is committed
|
||||
// only when this succeeds; otherwise restore the complete pre-edit record
|
||||
// so Desktop and the relay keep one authoritative name.
|
||||
@@ -902,15 +909,12 @@ pub async fn update_managed_agent(
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UpdateManagedAgentResponse {
|
||||
agent: summary,
|
||||
profile_sync_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Model normalization ───────────────────────────────────────────────────────
|
||||
|
||||
/// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend.
|
||||
///
|
||||
/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState),
|
||||
@@ -927,10 +931,8 @@ pub(super) fn normalize_agent_models(
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let mut models: Vec<AgentModelInfo> = Vec::new();
|
||||
let mut seen_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
// 1. Stable configOptions (preferred). Only entries with category "model"
|
||||
// are model options — the CLI pre-filters, but we're defensive here.
|
||||
if let Some(config_options) = raw["stable"]["configOptions"].as_array() {
|
||||
@@ -956,7 +958,6 @@ pub(super) fn normalize_agent_models(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Unstable availableModels (fallback — skip duplicates from stable).
|
||||
let mut agent_default_model: Option<String> = None;
|
||||
if let Some(unstable) = raw.get("unstable") {
|
||||
@@ -978,9 +979,7 @@ pub(super) fn normalize_agent_models(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let supports_switching = !models.is_empty();
|
||||
|
||||
AgentModelsResponse {
|
||||
agent_name,
|
||||
agent_version,
|
||||
@@ -990,7 +989,6 @@ pub(super) fn normalize_agent_models(
|
||||
supports_switching,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agent_models_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -25,6 +25,20 @@ pub(super) fn env_or_process_value(env: &BTreeMap<String, String>, key: &str) ->
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a trimmed mapped value even when it is blank, falling back to the
|
||||
/// process only when the map has no override. Optional credentials use this so
|
||||
/// an explicit blank means "send no authentication" rather than inheriting an
|
||||
/// unrelated process secret.
|
||||
pub(super) fn env_or_process_override(env: &BTreeMap<String, String>, key: &str) -> Option<String> {
|
||||
env.get(key)
|
||||
.map(|value| value.trim().to_string())
|
||||
.or_else(|| {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Clone `env` with `key` set to the value a request actually used, so error
|
||||
/// redaction masks the inherited process value and not just the mapped one.
|
||||
pub(super) fn redaction_env_with_value(
|
||||
|
||||
@@ -105,6 +105,22 @@ fn openai_models_url_uses_openai_default_base_url() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_compat_models_url_requires_custom_base_url() {
|
||||
let err = openai_compatible_models_url_for_discovery(Some("openai-compat"), &BTreeMap::new())
|
||||
.unwrap_err();
|
||||
assert!(err.contains("OPENAI_COMPAT_BASE_URL required"), "{err}");
|
||||
|
||||
let env = BTreeMap::from([(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"http://localhost:11434/v1/".to_string(),
|
||||
)]);
|
||||
assert_eq!(
|
||||
openai_compatible_models_url_for_discovery(Some("openai-compat"), &env).unwrap(),
|
||||
"http://localhost:11434/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_models_url_uses_anthropic_default_base_url() {
|
||||
assert_eq!(
|
||||
@@ -917,3 +933,64 @@ fn databricks_static_token_error_redacts_echoed_token() {
|
||||
"error lost its remediation: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_compat_discovery_omits_authorization_without_key() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}/v1", listener.local_addr().unwrap());
|
||||
let captured = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0u8; 4096];
|
||||
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let count = socket.read(&mut buffer).await.unwrap();
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
let body = r#"{"data":[{"id":"llama3","created":1}]}"#;
|
||||
socket
|
||||
.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&bytes).to_ascii_lowercase()
|
||||
});
|
||||
|
||||
let provider = effective_discovery_provider(Some("openai-compat"), None, &BTreeMap::new());
|
||||
let env = BTreeMap::from([("OPENAI_COMPAT_BASE_URL".to_string(), base_url)]);
|
||||
let result = discover_openai_compatible_models(&reqwest::Client::new(), &provider, &env, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.models[0].id, "llama3");
|
||||
let request = captured.await.unwrap();
|
||||
assert!(request.starts_with("get /v1/models "), "{request}");
|
||||
assert!(!request.contains("authorization:"), "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_compat_key_allows_explicit_blank_to_shadow_process_env() {
|
||||
let key = "BUZZ_TEST_OPENAI_COMPAT_API_KEY_OVERRIDE";
|
||||
let prior = std::env::var_os(key);
|
||||
std::env::set_var(key, "process-secret");
|
||||
|
||||
let env = BTreeMap::from([(key.to_string(), " ".to_string())]);
|
||||
assert_eq!(env_or_process_override(&env, key).as_deref(), Some(""));
|
||||
|
||||
match prior {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +516,12 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {
|
||||
key: "OPENAI_COMPAT_API_KEY".to_string(),
|
||||
});
|
||||
}
|
||||
Some("openai-compat")
|
||||
if env_key_missing("OPENAI_COMPAT_BASE_URL") => {
|
||||
missing.push(Requirement::EnvKey {
|
||||
key: "OPENAI_COMPAT_BASE_URL".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).
|
||||
@@ -630,6 +636,14 @@ fn goose_requirements(
|
||||
key: "OPENAI_COMPAT_API_KEY".to_string(),
|
||||
});
|
||||
}
|
||||
Some("openai-compat")
|
||||
if env_key_missing("OPENAI_COMPAT_BASE_URL")
|
||||
&& !file_key_present("OPENAI_COMPAT_BASE_URL") =>
|
||||
{
|
||||
missing.push(Requirement::EnvKey {
|
||||
key: "OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
});
|
||||
}
|
||||
Some("databricks") | Some("databricks_v2") | Some("databricks-v2")
|
||||
if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") =>
|
||||
{
|
||||
@@ -748,6 +762,35 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_openai_compat_requires_url_but_not_key() {
|
||||
let without_url = make_env(
|
||||
"buzz-agent",
|
||||
env_with(&[
|
||||
("BUZZ_AGENT_PROVIDER", "openai-compat"),
|
||||
("BUZZ_AGENT_MODEL", "llama3"),
|
||||
]),
|
||||
);
|
||||
let result = agent_readiness(&without_url);
|
||||
assert!(!result.is_ready());
|
||||
assert!(result.requirements().contains(&Requirement::EnvKey {
|
||||
key: "OPENAI_COMPAT_BASE_URL".to_string()
|
||||
}));
|
||||
assert!(!result.requirements().contains(&Requirement::EnvKey {
|
||||
key: "OPENAI_COMPAT_API_KEY".to_string()
|
||||
}));
|
||||
|
||||
let with_url = make_env(
|
||||
"buzz-agent",
|
||||
env_with(&[
|
||||
("BUZZ_AGENT_PROVIDER", "openai-compat"),
|
||||
("BUZZ_AGENT_MODEL", "llama3"),
|
||||
("OPENAI_COMPAT_BASE_URL", "http://localhost:11434/v1"),
|
||||
]),
|
||||
);
|
||||
assert!(agent_readiness(&with_url).is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_anthropic_with_all_fields_is_ready() {
|
||||
let env = make_env(
|
||||
@@ -1203,9 +1246,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── codex readiness version gate ───────────────────────────────────────
|
||||
|
||||
/// Build a minimal `KnownAcpRuntime` for testing the codex version gate.
|
||||
/// `adapter_commands` are the exact strings passed to `find_command` — use
|
||||
/// `&["codex-acp"]` when the binary is on PATH, or `&[<absolute_path>]`
|
||||
@@ -1249,48 +1290,40 @@ mod tests {
|
||||
auth_probe_args: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a temp dir containing a `codex-acp` script with the given body,
|
||||
/// prepend it to PATH, and clear the resolve cache. Returns the temp dir
|
||||
/// and the original PATH string for restoration.
|
||||
#[cfg(unix)]
|
||||
fn setup_temp_codex_acp(script_body: &str) -> (tempfile::TempDir, String) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let bin = dir.path().join("codex-acp");
|
||||
std::fs::write(&bin, script_body).expect("write script");
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod script");
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
let new_path = format!("{}:{}", dir.path().display(), original_path);
|
||||
std::env::set_var("PATH", &new_path);
|
||||
crate::managed_agents::clear_resolve_cache();
|
||||
|
||||
(dir, original_path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn leaked_adapter_commands(bin: &std::path::Path) -> &'static [&'static str] {
|
||||
let command = Box::leak(bin.display().to_string().into_boxed_str());
|
||||
Box::leak(vec![command as &'static str].into_boxed_slice())
|
||||
}
|
||||
|
||||
/// Restore PATH and clear the resolve cache after a PATH-mutating test.
|
||||
#[cfg(unix)]
|
||||
fn restore_path(original: &str) {
|
||||
std::env::set_var("PATH", original);
|
||||
crate::managed_agents::clear_resolve_cache();
|
||||
}
|
||||
|
||||
/// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated,
|
||||
/// login probe skipped.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() {
|
||||
let _guard = crate::managed_agents::lock_path_mutex();
|
||||
|
||||
let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n");
|
||||
let exe = present_binary_str();
|
||||
// Use the fixture's absolute adapter path here. Bare `codex-acp`
|
||||
@@ -1305,10 +1338,8 @@ mod tests {
|
||||
"run `codex login`",
|
||||
&rt,
|
||||
);
|
||||
|
||||
restore_path(&orig);
|
||||
drop(dir);
|
||||
|
||||
assert!(
|
||||
!reqs.is_empty(),
|
||||
"outdated codex adapter must produce a requirement; got {reqs:?}"
|
||||
@@ -1326,14 +1357,12 @@ mod tests {
|
||||
panic!("expected CliLogin requirement; got {:?}", reqs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex readiness: adapter exits 0 but output is not a parseable version
|
||||
/// → AdapterOutdated (garbage output treated as outdated, same as non-zero).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() {
|
||||
let _guard = crate::managed_agents::lock_path_mutex();
|
||||
|
||||
let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n");
|
||||
let exe = present_binary_str();
|
||||
let rt = make_codex_runtime(
|
||||
@@ -1345,10 +1374,8 @@ mod tests {
|
||||
"run `codex login`",
|
||||
&rt,
|
||||
);
|
||||
|
||||
restore_path(&orig);
|
||||
drop(dir);
|
||||
|
||||
assert!(
|
||||
!reqs.is_empty(),
|
||||
"garbage version output must produce a requirement; got {reqs:?}"
|
||||
@@ -1366,9 +1393,7 @@ mod tests {
|
||||
panic!("expected CliLogin requirement; got {:?}", reqs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── custom/unknown command ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unknown_command_is_always_ready() {
|
||||
// Since Phase B-7 (readiness exec-check), unknown/custom commands that are
|
||||
@@ -1381,7 +1406,6 @@ mod tests {
|
||||
"unknown/custom command present in PATH should be Ready"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_missing_from_path_is_not_ready() {
|
||||
let env = make_env("my-custom-harness-that-does-not-exist", BTreeMap::new());
|
||||
@@ -1397,14 +1421,11 @@ mod tests {
|
||||
"should surface MissingBinary requirement"
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentReadiness helpers ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn agent_readiness_ready_has_empty_requirements() {
|
||||
assert!(AgentReadiness::Ready.requirements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_readiness_not_ready_exposes_requirements() {
|
||||
let r = AgentReadiness::NotReady {
|
||||
@@ -1415,9 +1436,7 @@ mod tests {
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.requirements().len(), 1);
|
||||
}
|
||||
|
||||
// ── Requirement serialization ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn requirement_serializes_with_surface_tag() {
|
||||
let r = Requirement::NormalizedField {
|
||||
@@ -1427,13 +1446,11 @@ mod tests {
|
||||
assert_eq!(json["surface"], "normalized_field");
|
||||
assert_eq!(json["field"], "provider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_bash_requirement_serializes_correctly() {
|
||||
let json = serde_json::to_value(Requirement::GitBash).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "surface": "git_bash" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_key_requirement_serializes_correctly() {
|
||||
let r = Requirement::EnvKey {
|
||||
@@ -1443,7 +1460,6 @@ mod tests {
|
||||
assert_eq!(json["surface"], "env_key");
|
||||
assert_eq!(json["key"], "ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_login_requirement_serializes_correctly() {
|
||||
let r = Requirement::CliLogin {
|
||||
@@ -1460,9 +1476,7 @@ mod tests {
|
||||
assert!(json["probe_args"].is_array());
|
||||
assert!(json["setup_copy"].as_str().unwrap().contains("codex login"));
|
||||
}
|
||||
|
||||
// ── resolve_effective_agent_env ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_effective_agent_env_user_env_wins_over_structured_fields() {
|
||||
// A record whose env_vars explicitly set provider/model must win over
|
||||
@@ -1474,7 +1488,6 @@ mod tests {
|
||||
"BUZZ_AGENT_MODEL".to_string(),
|
||||
"claude-opus-4-5".to_string(),
|
||||
);
|
||||
|
||||
// Minimal record: only the fields resolve_effective_agent_env reads.
|
||||
let record = crate::managed_agents::types::ManagedAgentRecord {
|
||||
pubkey: "test-pubkey".to_string(),
|
||||
@@ -1531,10 +1544,8 @@ mod tests {
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
};
|
||||
|
||||
let runtime = known_acp_runtime_exact("buzz-agent");
|
||||
let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default());
|
||||
|
||||
// User env_vars must be present in the output (last-write-wins).
|
||||
assert_eq!(
|
||||
effective.env.get("BUZZ_AGENT_PROVIDER").map(String::as_str),
|
||||
@@ -1545,9 +1556,7 @@ mod tests {
|
||||
Some("claude-opus-4-5")
|
||||
);
|
||||
}
|
||||
|
||||
// ── provider-specific model fallback tests ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() {
|
||||
// The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL.
|
||||
@@ -1565,7 +1574,6 @@ mod tests {
|
||||
"DATABRICKS_MODEL must satisfy the model requirement for databricks_v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_databricks_v2_hyphen_alias_with_databricks_model_is_ready() {
|
||||
// buzz-agent accepts both "databricks_v2" and "databricks-v2". The
|
||||
@@ -1583,7 +1591,6 @@ mod tests {
|
||||
"databricks-v2 alias with DATABRICKS_MODEL must be Ready"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_databricks_hyphen_alias_missing_host_returns_not_ready() {
|
||||
// The hyphen alias "databricks-v2" requires DATABRICKS_HOST just like
|
||||
@@ -1608,7 +1615,6 @@ mod tests {
|
||||
"missing requirements must include DATABRICKS_HOST; got {reqs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_databricks_v1_with_databricks_model_but_no_buzz_agent_model_is_ready() {
|
||||
// V1 (Model Serving) also resolves DATABRICKS_MODEL — same fallback applies.
|
||||
@@ -1625,7 +1631,6 @@ mod tests {
|
||||
"DATABRICKS_MODEL must satisfy the model requirement for databricks (V1)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_anthropic_with_anthropic_model_but_no_buzz_agent_model_is_ready() {
|
||||
let env = make_env(
|
||||
@@ -1641,7 +1646,6 @@ mod tests {
|
||||
"ANTHROPIC_MODEL must satisfy the model requirement for anthropic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_openai_with_openai_compat_model_but_no_buzz_agent_model_is_ready() {
|
||||
let env = make_env(
|
||||
@@ -1657,7 +1661,6 @@ mod tests {
|
||||
"OPENAI_COMPAT_MODEL must satisfy the model requirement for openai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_empty_provider_model_fallback_key_is_not_ready() {
|
||||
// An empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must still be NotReady.
|
||||
@@ -1680,9 +1683,7 @@ mod tests {
|
||||
field: "model".to_string()
|
||||
}));
|
||||
}
|
||||
|
||||
// ── OpenRouter readiness ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_openrouter_with_all_fields_is_ready() {
|
||||
let env = make_env(
|
||||
@@ -1699,7 +1700,6 @@ mod tests {
|
||||
"openrouter with all fields should be ready"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_openrouter_missing_key_returns_not_ready() {
|
||||
let env = make_env(
|
||||
@@ -1715,7 +1715,6 @@ mod tests {
|
||||
key: "OPENROUTER_API_KEY".to_string()
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() {
|
||||
let env = make_env(
|
||||
@@ -1733,7 +1732,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Goose file-config-aware requirement tests live in a sibling file so this
|
||||
// module stays under the desktop file-size ratchet.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
AgentModelField,
|
||||
} from "@/features/agents/ui/agentConfigControls";
|
||||
import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField";
|
||||
import {
|
||||
OPENAI_COMPAT_BASE_URL,
|
||||
OpenAiCompatibleBaseUrlField,
|
||||
openAiCompatibleBaseUrlError,
|
||||
} from "@/features/agents/ui/OpenAiCompatibleBaseUrlField";
|
||||
import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery";
|
||||
import {
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
@@ -97,7 +102,6 @@ const autoSelectModelOnProviderChange = true;
|
||||
const disableModelSelectDuringDiscovery = false;
|
||||
const preserveCredentialEnvVarsOnProviderChange = true;
|
||||
const requireProviderForModelAndEffort = true;
|
||||
|
||||
/** The canonical behavior contract, exported for the contract test. */
|
||||
export const CANONICAL_CONFIG_BEHAVIORS = {
|
||||
autoSelectModelOnProviderChange,
|
||||
@@ -105,7 +109,6 @@ export const CANONICAL_CONFIG_BEHAVIORS = {
|
||||
preserveCredentialEnvVarsOnProviderChange,
|
||||
requireProviderForModelAndEffort,
|
||||
} as const;
|
||||
|
||||
/** Disclosure preset → the eight visibility decisions it owns. Exported for the contract test. */
|
||||
export function resolveDisclosure(disclosure: AgentConfigDisclosure) {
|
||||
const full = disclosure !== "onboarding-essential";
|
||||
@@ -120,7 +123,6 @@ export function resolveDisclosure(disclosure: AgentConfigDisclosure) {
|
||||
showUnavailableEffortOptions: full,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function shouldRevealDependentConfigFields({
|
||||
disclosure,
|
||||
providerFieldVisible,
|
||||
@@ -136,7 +138,6 @@ export function shouldRevealDependentConfigFields({
|
||||
providerValue.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the status line under the Model field renders. Discovery warnings bypass onboarding-essential so first-run failures are never invisible. */
|
||||
export function shouldShowModelStatusMessage(
|
||||
showDescriptions: boolean,
|
||||
@@ -144,7 +145,6 @@ export function shouldShowModelStatusMessage(
|
||||
): boolean {
|
||||
return showDescriptions || status !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Model control given discovery state. Optional-model harnesses omit it while
|
||||
* discovery is loading or after confirmed successful empty; failures keep it for the #2246 UI.
|
||||
@@ -173,7 +173,6 @@ export function shouldRenderModelControl({
|
||||
// Omit only on confirmed successful empty — not on failure/unavailable.
|
||||
return !modelDiscoverySuccessfulEmpty;
|
||||
}
|
||||
|
||||
export type AgentConfigFieldsProps = {
|
||||
bakedEnv: BakedEnvEntry[];
|
||||
selectedRuntime: AcpRuntimeCatalogEntry | undefined;
|
||||
@@ -209,7 +208,6 @@ export type AgentConfigFieldsProps = {
|
||||
useCustomSelect?: boolean;
|
||||
useChevronSelectIcon?: boolean;
|
||||
};
|
||||
|
||||
export function AgentConfigFields({
|
||||
bakedEnv,
|
||||
selectedRuntime,
|
||||
@@ -239,7 +237,6 @@ export function AgentConfigFields({
|
||||
showRequiredIndicators,
|
||||
showUnavailableEffortOptions,
|
||||
} = resolveDisclosure(disclosure);
|
||||
|
||||
const fieldModel = React.useMemo(
|
||||
() =>
|
||||
deriveAgentConfigFieldModel({
|
||||
@@ -254,7 +251,6 @@ export function AgentConfigFields({
|
||||
effortField?.currentPersistence.kind === "envVar"
|
||||
? effortField.currentPersistence.key
|
||||
: null;
|
||||
|
||||
const numericDescriptors = fieldModel.fields.filter(
|
||||
(d): d is NumericDescriptor =>
|
||||
(d.kind === "maxOutputTokens" ||
|
||||
@@ -306,7 +302,6 @@ export function AgentConfigFields({
|
||||
]),
|
||||
[bakedEnv, allStructuredKeys],
|
||||
);
|
||||
|
||||
const providerValue = providerFieldVisible ? (config.provider ?? "") : "";
|
||||
const providerForDiscovery =
|
||||
providerFieldVisible && !isCustomProvider
|
||||
@@ -342,6 +337,7 @@ export function AgentConfigFields({
|
||||
apiKeyEnvVar,
|
||||
apiKeyFileSatisfied,
|
||||
apiKeyInherited,
|
||||
apiKeyRequired,
|
||||
apiKeyValue,
|
||||
credentialsValid,
|
||||
} = getGlobalAgentCredentialState({
|
||||
@@ -351,12 +347,23 @@ export function AgentConfigFields({
|
||||
runtimeFileConfig,
|
||||
runtimeId: credentialRuntimeId,
|
||||
});
|
||||
const compatibleBaseUrl = config.env_vars[OPENAI_COMPAT_BASE_URL] ?? "";
|
||||
const compatibleBaseUrlInherited =
|
||||
effectiveProvider === "openai-compat" &&
|
||||
compatibleBaseUrl.trim().length === 0 &&
|
||||
credentialsValid;
|
||||
const compatibleBaseUrlValid =
|
||||
effectiveProvider !== "openai-compat" ||
|
||||
compatibleBaseUrlInherited ||
|
||||
openAiCompatibleBaseUrlError(compatibleBaseUrl) === null;
|
||||
const configIsValid =
|
||||
selectedRuntimeId.length > 0 && modelIsValid && credentialsValid;
|
||||
selectedRuntimeId.length > 0 &&
|
||||
modelIsValid &&
|
||||
credentialsValid &&
|
||||
compatibleBaseUrlValid;
|
||||
React.useEffect(() => {
|
||||
onValidityChange?.(configIsValid);
|
||||
}, [configIsValid, onValidityChange]);
|
||||
|
||||
const {
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryLoading,
|
||||
@@ -382,7 +389,6 @@ export function AgentConfigFields({
|
||||
modelIsOptional,
|
||||
showCustomModelOption,
|
||||
});
|
||||
|
||||
// Mount-time healing policy: onboarding page 4 edits the root config during
|
||||
// first-run (no higher layers to inherit from), so acting on open is safe
|
||||
// and intentional there — it heals stale state and picks a valid model.
|
||||
@@ -400,7 +406,6 @@ export function AgentConfigFields({
|
||||
const mayMutateDependentFieldsRef = React.useRef(false);
|
||||
mayMutateDependentFieldsRef.current =
|
||||
healOnMount || userEditedProviderRef.current;
|
||||
|
||||
const autoSelectedModelScopeRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!autoSelectModelOnProviderChange) return;
|
||||
@@ -414,12 +419,10 @@ export function AgentConfigFields({
|
||||
if (modelDiscoveryLoading || discoveredModelOptions === null) return;
|
||||
const selectionScope = `${selectedRuntimeId}:${trimmedProvider}`;
|
||||
if (autoSelectedModelScopeRef.current === selectionScope) return;
|
||||
|
||||
const firstModel = discoveredModelOptions.find(
|
||||
(option) => option.id.trim().length > 0,
|
||||
);
|
||||
if (!firstModel) return;
|
||||
|
||||
autoSelectedModelScopeRef.current = selectionScope;
|
||||
onCustomModelEditingChange(false);
|
||||
onConfigChange({ ...config, model: firstModel.id });
|
||||
@@ -433,11 +436,9 @@ export function AgentConfigFields({
|
||||
providerForDiscovery,
|
||||
selectedRuntimeId,
|
||||
]);
|
||||
|
||||
const currentEffortForAutoClear = effortPersistenceKey
|
||||
? (config.env_vars[effortPersistenceKey] ?? "")
|
||||
: "";
|
||||
|
||||
// When the selected harness changes outside this component (Back → setup
|
||||
// page → choose a different harness → Next), the saved model can belong to
|
||||
// the old harness. In onboarding, heal that stale value as soon as the new
|
||||
@@ -451,7 +452,6 @@ export function AgentConfigFields({
|
||||
const currentModel = (config.model ?? "").trim();
|
||||
if (currentModel.length === 0) return;
|
||||
if (modelDiscoveryLoading) return;
|
||||
|
||||
const catalogMiss =
|
||||
discoveredModelOptions !== null &&
|
||||
!discoveredModelOptions.some(
|
||||
@@ -460,7 +460,6 @@ export function AgentConfigFields({
|
||||
const omittedAfterSuccessfulEmpty =
|
||||
modelIsOptional && !modelControlVisible && modelDiscoverySuccessfulEmpty;
|
||||
if (!catalogMiss && !omittedAfterSuccessfulEmpty) return;
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
onCustomModelEditingChange(false);
|
||||
@@ -477,7 +476,6 @@ export function AgentConfigFields({
|
||||
healOnMount,
|
||||
effortPersistenceKey,
|
||||
]);
|
||||
|
||||
// Orphan-model clearing follows the mount-time healing policy above: the
|
||||
// backend resolves provider and model independently across layers
|
||||
// (agent → definition → global), so a saved global model WITHOUT a global
|
||||
@@ -495,7 +493,6 @@ export function AgentConfigFields({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
onCustomModelEditingChange(false);
|
||||
@@ -521,7 +518,6 @@ export function AgentConfigFields({
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars });
|
||||
},
|
||||
});
|
||||
|
||||
function handleProviderChange(value: string) {
|
||||
userEditedProviderRef.current = true;
|
||||
const previousApiKey = getProviderApiKeyEnvVar(effectiveProvider);
|
||||
@@ -548,7 +544,6 @@ export function AgentConfigFields({
|
||||
delete nextEnvVars[previousApiKey];
|
||||
}
|
||||
const providerChanged = nextProvider !== (config.provider ?? null);
|
||||
|
||||
onIsCustomProviderChange(false);
|
||||
onConfigChange({
|
||||
...config,
|
||||
@@ -562,28 +557,23 @@ export function AgentConfigFields({
|
||||
: config.model,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCustomProviderInput(value: string) {
|
||||
onConfigChange({ ...config, provider: value || null });
|
||||
}
|
||||
|
||||
function handleModelChange(value: string) {
|
||||
onConfigChange({
|
||||
...config,
|
||||
model: config.provider === "relay-mesh" ? value || "auto" : value || null,
|
||||
});
|
||||
}
|
||||
|
||||
function handleEnvVarsChange(next: Record<string, string>) {
|
||||
onConfigChange({ ...config, env_vars: next });
|
||||
}
|
||||
|
||||
const handleNumericEnvVarChange = (key: string, value: string) => {
|
||||
const next = { ...config.env_vars, [key]: value };
|
||||
if (value === "") delete next[key];
|
||||
onConfigChange({ ...config, env_vars: next });
|
||||
};
|
||||
|
||||
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
|
||||
// migration rewrites v1→v2. Hide the legacy v1 option so it is not offered
|
||||
// for new selections; OSS builds show it.
|
||||
@@ -608,7 +598,6 @@ export function AgentConfigFields({
|
||||
const providerSelectValue = isCustomProvider
|
||||
? CUSTOM_PROVIDER_DROPDOWN_VALUE
|
||||
: providerValue || AUTO_PROVIDER_DROPDOWN_VALUE;
|
||||
|
||||
const providerZeroLabel = React.useMemo(() => {
|
||||
if (!bakedProvider) return null;
|
||||
return getBakedProviderInheritLabel(bakedProvider, providerOptions);
|
||||
@@ -622,7 +611,6 @@ export function AgentConfigFields({
|
||||
}
|
||||
return "Select a provider";
|
||||
}, [bakedProvider, providerOptions]);
|
||||
|
||||
const implicitEffortProvider =
|
||||
selectedRuntimeId === "claude"
|
||||
? "anthropic"
|
||||
@@ -638,7 +626,6 @@ export function AgentConfigFields({
|
||||
? (config.env_vars[effortPersistenceKey] ?? "")
|
||||
: "";
|
||||
const effortFieldVisible = showEffortField && effortField !== undefined;
|
||||
|
||||
const progressiveDefaults = disclosure === "progressive-defaults";
|
||||
const fieldClassName = unstyled
|
||||
? progressiveDefaults
|
||||
@@ -707,7 +694,6 @@ export function AgentConfigFields({
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
const providerContent = providerFieldVisible ? (
|
||||
<div className={fieldClassName}>
|
||||
<label
|
||||
@@ -739,13 +725,15 @@ export function AgentConfigFields({
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const advancedEditorBlock = (
|
||||
<>
|
||||
<EnvVarsEditor
|
||||
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
|
||||
hiddenKeys={[
|
||||
...(apiKeyEnvVar ? [apiKeyEnvVar] : []),
|
||||
...(effectiveProvider === "openai-compat"
|
||||
? [OPENAI_COMPAT_BASE_URL]
|
||||
: []),
|
||||
...allStructuredKeys,
|
||||
]}
|
||||
inheritedRows={bakedGenericRows}
|
||||
@@ -766,9 +754,23 @@ export function AgentConfigFields({
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const dependentContent = (
|
||||
<>
|
||||
{providerFieldVisible && effectiveProvider === "openai-compat" ? (
|
||||
<div className={blockClassName}>
|
||||
<OpenAiCompatibleBaseUrlField
|
||||
disabled={false}
|
||||
inherited={compatibleBaseUrlInherited}
|
||||
onValueChange={(next) =>
|
||||
handleEnvVarsChange({
|
||||
...config.env_vars,
|
||||
[OPENAI_COMPAT_BASE_URL]: next,
|
||||
})
|
||||
}
|
||||
value={compatibleBaseUrl}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{providerFieldVisible && apiKeyEnvVar ? (
|
||||
<div className={blockClassName}>
|
||||
<PersonaProviderApiKeyField
|
||||
@@ -780,7 +782,9 @@ export function AgentConfigFields({
|
||||
: "Provided by this build"
|
||||
}
|
||||
isInherited={apiKeyInherited}
|
||||
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
|
||||
isRequired={
|
||||
apiKeyRequired && !apiKeyInherited && apiKeyValue.length === 0
|
||||
}
|
||||
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
@@ -792,7 +796,6 @@ export function AgentConfigFields({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Model field — omitted only after confirmed successful empty discovery */}
|
||||
{modelControlVisible ? (
|
||||
<div className={showDescriptions ? fieldClassName : undefined}>
|
||||
@@ -843,7 +846,6 @@ export function AgentConfigFields({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Thinking / Effort */}
|
||||
{effortFieldVisible ? (
|
||||
<div className={blockClassName}>
|
||||
@@ -889,7 +891,6 @@ export function AgentConfigFields({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showAdvancedFields ? (
|
||||
<div className={cn(blockClassName, "space-y-3")}>
|
||||
<CardMintKeyCue envVars={config.env_vars} />
|
||||
@@ -942,7 +943,6 @@ export function AgentConfigFields({
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{providerContent}
|
||||
@@ -974,7 +974,6 @@ export function AgentConfigFields({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (unstyled) {
|
||||
return (
|
||||
<div
|
||||
@@ -985,7 +984,6 @@ export function AgentConfigFields({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsOptionGroup data-testid="global-agent-config-fields">
|
||||
{content}
|
||||
|
||||
@@ -17,6 +17,11 @@ import { PersonaAdvancedFields } from "./PersonaAdvancedFields";
|
||||
import { PersonaModelField } from "./PersonaModelField";
|
||||
import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning";
|
||||
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
|
||||
import {
|
||||
OPENAI_COMPAT_BASE_URL,
|
||||
OpenAiCompatibleBaseUrlField,
|
||||
openAiCompatibleBaseUrlError,
|
||||
} from "./OpenAiCompatibleBaseUrlField";
|
||||
import {
|
||||
canSubmitPersonaDialog,
|
||||
formatPersonaNamePoolText,
|
||||
@@ -260,7 +265,6 @@ export function AgentDefinitionDialog({
|
||||
isRuntimeAutoSeededRef.current = true;
|
||||
}
|
||||
}, [defaultRuntime, initialValues, open, runtime, runtimesLoading]);
|
||||
|
||||
// Keep an inherited Create runtime synced with defaults saved in-place.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -275,7 +279,6 @@ export function AgentDefinitionDialog({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (runtime !== defaultRuntime.id) setRuntime(defaultRuntime.id);
|
||||
isRuntimeAutoSeededRef.current = true;
|
||||
hasSeededForOpenRef.current = true;
|
||||
@@ -287,7 +290,6 @@ export function AgentDefinitionDialog({
|
||||
runtime,
|
||||
runtimesLoading,
|
||||
]);
|
||||
|
||||
// Keep setup guidance reachable when no available runtime can be inherited.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -300,7 +302,6 @@ export function AgentDefinitionDialog({
|
||||
setAiConfigurationMode("custom");
|
||||
}
|
||||
}, [defaultRuntime, isCreateMode, open, runtime, runtimesLoading]);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
// The catalog may veto embedded close requests; preserve the draft until unmount.
|
||||
if (!next && !embedded) {
|
||||
@@ -324,15 +325,12 @@ export function AgentDefinitionDialog({
|
||||
// isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the
|
||||
// [initialValues, open] effect resets both when the dialog re-opens.
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// D1: the same localModeSatisfied gate as canSubmit prevents form-submit
|
||||
// (Enter) from bypassing a missing credential.
|
||||
if (!initialValues || !localModeSatisfied || !canSubmit) return;
|
||||
|
||||
const {
|
||||
runtime: runtimeForSubmit,
|
||||
model: modelForSubmit,
|
||||
@@ -370,7 +368,6 @@ export function AgentDefinitionDialog({
|
||||
"id" in initialValues,
|
||||
),
|
||||
};
|
||||
|
||||
if ("id" in initialValues) {
|
||||
await onSubmit(
|
||||
{
|
||||
@@ -383,15 +380,12 @@ export function AgentDefinitionDialog({
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(baseInput, { publishCatalogUpdates: false });
|
||||
}
|
||||
|
||||
function handleSubmitForm(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
|
||||
const selectedRuntime = runtimes.find((p) => p.id === runtime);
|
||||
const blankRuntimeModelProviderEditable =
|
||||
initialModelProviderEditableWithoutRuntime && runtime.trim().length === 0;
|
||||
@@ -500,6 +494,14 @@ export function AgentDefinitionDialog({
|
||||
selectedRuntime?.availability === "available";
|
||||
// Gate model/provider validity through missingNormalizedFields — single
|
||||
// source of truth with the readiness gate so display and Save can't drift.
|
||||
const compatibleBaseUrl = envVars[OPENAI_COMPAT_BASE_URL] ?? "";
|
||||
const compatibleBaseUrlInherited =
|
||||
compatibleBaseUrl.trim().length === 0 &&
|
||||
localModeGate.missingEnvKeys.every((key) => key !== OPENAI_COMPAT_BASE_URL);
|
||||
const compatibleBaseUrlValid =
|
||||
effectiveProvider !== "openai-compat" ||
|
||||
compatibleBaseUrlInherited ||
|
||||
openAiCompatibleBaseUrlError(compatibleBaseUrl) === null;
|
||||
const canSubmit =
|
||||
canSubmitPersonaDialog({ displayName, isPending }) &&
|
||||
(!isCreateMode || runtime.trim().length > 0) &&
|
||||
@@ -511,9 +513,9 @@ export function AgentDefinitionDialog({
|
||||
// D1: localModeSatisfied covers both missingNormalizedFields AND
|
||||
// missingEnvKeys — credential env keys now block submit, not just display.
|
||||
localModeSatisfied &&
|
||||
compatibleBaseUrlValid &&
|
||||
customAiPairSatisfied &&
|
||||
!isAvatarUploadPending;
|
||||
|
||||
// Merge global env as the base layer so credential keys satisfied via global
|
||||
// config are available to model discovery — same rationale as in AgentInstanceEditDialog.
|
||||
const envVarsForDiscovery = React.useMemo(
|
||||
@@ -629,7 +631,6 @@ export function AgentDefinitionDialog({
|
||||
const advancedFieldsTransition = shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: ADVANCED_FIELDS_MOTION_TRANSITION;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
@@ -643,7 +644,6 @@ export function AgentDefinitionDialog({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModel("");
|
||||
setIsCustomModelEditing(false);
|
||||
}, [
|
||||
@@ -654,7 +654,6 @@ export function AgentDefinitionDialog({
|
||||
effectiveProvider,
|
||||
runtime,
|
||||
]);
|
||||
|
||||
const selection: RuntimeModelProviderSelection = {
|
||||
provider,
|
||||
model,
|
||||
@@ -662,7 +661,6 @@ export function AgentDefinitionDialog({
|
||||
isCustomModelEditing,
|
||||
envVars,
|
||||
};
|
||||
|
||||
function applySelection(next: RuntimeModelProviderSelection) {
|
||||
setProvider(next.provider);
|
||||
setModel(next.model);
|
||||
@@ -670,7 +668,6 @@ export function AgentDefinitionDialog({
|
||||
setIsCustomModelEditing(next.isCustomModelEditing);
|
||||
setEnvVars(next.envVars);
|
||||
}
|
||||
|
||||
function handleRuntimeDropdownChange(nextValue: string) {
|
||||
const action = runtimeDropdownAction(nextValue);
|
||||
if (action.kind === "add-custom-harness") {
|
||||
@@ -693,7 +690,6 @@ export function AgentDefinitionDialog({
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Routed through the normal change handler so a harness registered inline
|
||||
// resets model/provider exactly as a hand-picked one would. Scoped to `open`
|
||||
// so a pending id can't outlive the dialog that started the registration.
|
||||
@@ -702,7 +698,6 @@ export function AgentDefinitionDialog({
|
||||
handleRuntimeDropdownChange,
|
||||
open,
|
||||
);
|
||||
|
||||
function handleProviderDropdownChange(nextValue: string) {
|
||||
setHasUserChanges(true);
|
||||
const nextProvider =
|
||||
@@ -720,7 +715,6 @@ export function AgentDefinitionDialog({
|
||||
model: nextProvider === "relay-mesh" ? "auto" : nextSelection.model,
|
||||
});
|
||||
}
|
||||
|
||||
function handleModelDropdownChange(nextValue: string) {
|
||||
setHasUserChanges(true);
|
||||
applySelection(
|
||||
@@ -731,7 +725,6 @@ export function AgentDefinitionDialog({
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<AgentDefinitionDialogFooter
|
||||
canSubmit={canSubmit}
|
||||
@@ -764,7 +757,6 @@ export function AgentDefinitionDialog({
|
||||
setAvatarUrl(nextAvatarUrl);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
@@ -793,7 +785,6 @@ export function AgentDefinitionDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
@@ -815,7 +806,6 @@ export function AgentDefinitionDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelFieldVisible ? (
|
||||
<AgentAiConfigurationModeField
|
||||
mode={aiConfigurationMode}
|
||||
@@ -823,7 +813,6 @@ export function AgentDefinitionDialog({
|
||||
onModeChange={handleAiConfigurationModeChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="space-y-5"
|
||||
data-testid={`agent-${aiConfigurationMode}-configuration-section`}
|
||||
@@ -838,7 +827,6 @@ export function AgentDefinitionDialog({
|
||||
warning={runtimeWarning}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
|
||||
<div className="space-y-1.5">
|
||||
<RequiredFieldLabel
|
||||
@@ -882,7 +870,21 @@ export function AgentDefinitionDialog({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{llmProviderFieldVisible &&
|
||||
aiConfigurationMode === "custom" &&
|
||||
effectiveProvider === "openai-compat" ? (
|
||||
<OpenAiCompatibleBaseUrlField
|
||||
disabled={isPending}
|
||||
inherited={compatibleBaseUrlInherited}
|
||||
onValueChange={(next) =>
|
||||
setEnvVars((prev) => ({
|
||||
...prev,
|
||||
[OPENAI_COMPAT_BASE_URL]: next,
|
||||
}))
|
||||
}
|
||||
value={compatibleBaseUrl}
|
||||
/>
|
||||
) : null}
|
||||
{llmProviderFieldVisible &&
|
||||
aiConfigurationMode === "custom" &&
|
||||
topLevelSecretEnvVar ? (
|
||||
@@ -902,7 +904,6 @@ export function AgentDefinitionDialog({
|
||||
value={apiKeyValue}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{modelFieldVisible && aiConfigurationMode === "custom" ? (
|
||||
<PersonaModelField
|
||||
@@ -922,7 +923,6 @@ export function AgentDefinitionDialog({
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{aiConfigurationMode === "defaults" ? (
|
||||
<AgentCreateAiDefaultsSummary
|
||||
canChooseProvider={runtimeCanChooseLlmProvider}
|
||||
@@ -936,19 +936,16 @@ export function AgentDefinitionDialog({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AgentDefaultsDialog
|
||||
onOpenChange={setAiDefaultsOpen}
|
||||
open={runtimeCanChooseLlmProvider && aiDefaultsOpen}
|
||||
returnFocusRef={aiDefaultsTriggerRef}
|
||||
/>
|
||||
|
||||
<AddCustomHarnessDialog
|
||||
onOpenChange={setIsAddHarnessOpen}
|
||||
onSaved={selectSavedHarness}
|
||||
open={isAddHarnessOpen}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
aria-expanded={showAdvancedFields}
|
||||
@@ -992,9 +989,12 @@ export function AgentDefinitionDialog({
|
||||
disabled={isPending}
|
||||
envVars={envVars}
|
||||
fileSatisfiedEnvKeys={localModeGate.fileSatisfiedEnvKeys}
|
||||
hiddenEnvKeys={
|
||||
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
|
||||
}
|
||||
hiddenEnvKeys={[
|
||||
...(topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []),
|
||||
...(effectiveProvider === "openai-compat"
|
||||
? [OPENAI_COMPAT_BASE_URL]
|
||||
: []),
|
||||
]}
|
||||
inheritedEnvVars={inheritedEnvVarsForAdvanced}
|
||||
model={model}
|
||||
modelTuningRuntimeId={runtime}
|
||||
@@ -1014,14 +1014,12 @@ export function AgentDefinitionDialog({
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentDefinitionDialogShell
|
||||
description={description}
|
||||
|
||||
@@ -74,6 +74,11 @@ import {
|
||||
usePersonaModelDiscovery,
|
||||
} from "./usePersonaModelDiscovery";
|
||||
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
|
||||
import {
|
||||
OPENAI_COMPAT_BASE_URL,
|
||||
OpenAiCompatibleBaseUrlField,
|
||||
openAiCompatibleBaseUrlError,
|
||||
} from "./OpenAiCompatibleBaseUrlField";
|
||||
import {
|
||||
getBakedModelInheritLabel,
|
||||
getBakedProviderInheritLabel,
|
||||
@@ -391,12 +396,10 @@ export function AgentInstanceEditDialog({
|
||||
globalEnvVars: globalConfig.env_vars,
|
||||
personaEnvVars: inheritHarness ? inheritedEnvVars : undefined,
|
||||
});
|
||||
|
||||
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Merge global env as the base layer so credential keys satisfied via global
|
||||
// config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use
|
||||
// `inheritedSubmission.envVars` (the same snapshot the credential gate
|
||||
@@ -411,7 +414,6 @@ export function AgentInstanceEditDialog({
|
||||
(inheritedSubmission.provider ?? "").trim() ||
|
||||
inheritedProviderDefault.value;
|
||||
const providerForDiscovery = llmProviderFieldVisible ? effectiveProvider : "";
|
||||
|
||||
const {
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryLoading,
|
||||
@@ -424,7 +426,6 @@ export function AgentInstanceEditDialog({
|
||||
provider: providerForDiscovery,
|
||||
selectedRuntime,
|
||||
});
|
||||
|
||||
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display.
|
||||
// The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating.
|
||||
// D2/D3: the top-level API key owns display, while the readiness gate keeps
|
||||
@@ -466,7 +467,6 @@ export function AgentInstanceEditDialog({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModel("");
|
||||
setIsCustomModelEditing(false);
|
||||
}, [
|
||||
@@ -477,7 +477,6 @@ export function AgentInstanceEditDialog({
|
||||
selectedRuntime,
|
||||
selectedRuntimeId,
|
||||
]);
|
||||
|
||||
const selection: RuntimeModelProviderSelection = {
|
||||
provider,
|
||||
model,
|
||||
@@ -485,7 +484,6 @@ export function AgentInstanceEditDialog({
|
||||
isCustomModelEditing,
|
||||
envVars,
|
||||
};
|
||||
|
||||
function applySelection(next: RuntimeModelProviderSelection) {
|
||||
setProvider(next.provider);
|
||||
setModel(next.model);
|
||||
@@ -493,7 +491,6 @@ export function AgentInstanceEditDialog({
|
||||
setIsCustomModelEditing(next.isCustomModelEditing);
|
||||
setEnvVars(next.envVars);
|
||||
}
|
||||
|
||||
function handleRuntimeDropdownChange(nextValue: string) {
|
||||
const action = runtimeDropdownAction(nextValue);
|
||||
if (action.kind === "add-custom-harness") {
|
||||
@@ -503,16 +500,12 @@ export function AgentInstanceEditDialog({
|
||||
const nextRuntimeId = action.runtimeId;
|
||||
const previousRuntimeId = selectedRuntimeId;
|
||||
const nextRuntime = runtimes.find((r) => r.id === nextRuntimeId);
|
||||
|
||||
// Mark that the user has made an explicit runtime choice. The catalog-arrival
|
||||
// effect will no longer overwrite selectedRuntimeId after this point.
|
||||
runtimeTouched.current = true;
|
||||
|
||||
const resolvedRuntimeId = nextRuntimeId || "custom";
|
||||
setSelectedRuntimeId(resolvedRuntimeId);
|
||||
|
||||
const isCustomCommand = resolvedRuntimeId === "custom";
|
||||
|
||||
// Only pin the harness when the selection can actually supply a command:
|
||||
// - "Custom command": the Advanced command input becomes editable, so the
|
||||
// user provides the command.
|
||||
@@ -526,7 +519,6 @@ export function AgentInstanceEditDialog({
|
||||
if (isCustomCommand || nextRuntime?.command) {
|
||||
setInheritHarness(false);
|
||||
}
|
||||
|
||||
// When switching to a catalog-known runtime, update the agent command to
|
||||
// its resolved command so the command field stays consistent.
|
||||
if (nextRuntime?.command) {
|
||||
@@ -534,7 +526,6 @@ export function AgentInstanceEditDialog({
|
||||
const newArgs = nextRuntime.defaultArgs.join(",");
|
||||
setAgentArgs(newArgs);
|
||||
}
|
||||
|
||||
applySelection(
|
||||
selectionOnRuntimeChange(selection, {
|
||||
previousRuntime: previousRuntimeId,
|
||||
@@ -546,7 +537,6 @@ export function AgentInstanceEditDialog({
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Routed through the normal change handler so a harness registered inline
|
||||
// pins its command and resets model/provider like a hand-picked one. Scoped
|
||||
// to `open` so a pending id can't outlive the dialog that started the
|
||||
@@ -556,7 +546,6 @@ export function AgentInstanceEditDialog({
|
||||
handleRuntimeDropdownChange,
|
||||
open,
|
||||
);
|
||||
|
||||
function handleProviderDropdownChange(nextValue: string) {
|
||||
const nextProvider =
|
||||
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
|
||||
@@ -576,7 +565,6 @@ export function AgentInstanceEditDialog({
|
||||
model: nextProvider === "relay-mesh" ? "auto" : nextSelection.model,
|
||||
});
|
||||
}
|
||||
|
||||
function handleModelDropdownChange(nextValue: string) {
|
||||
applySelection(
|
||||
selectionOnModelDropdownChange(selection, {
|
||||
@@ -586,11 +574,9 @@ export function AgentInstanceEditDialog({
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
const providerValid = isEditAgentProviderSaveValid({
|
||||
llmProviderFieldVisible,
|
||||
currentProvider: provider,
|
||||
@@ -598,7 +584,17 @@ export function AgentInstanceEditDialog({
|
||||
globalProvider: inheritedProviderDefault.value,
|
||||
originalRuntimeSupportsProvider,
|
||||
});
|
||||
|
||||
const compatibleBaseUrl = envVars[OPENAI_COMPAT_BASE_URL] ?? "";
|
||||
const compatibleBaseUrlInherited =
|
||||
!(OPENAI_COMPAT_BASE_URL in envVars) &&
|
||||
((inheritedSubmission.envVars[OPENAI_COMPAT_BASE_URL] ?? "").trim().length >
|
||||
0 ||
|
||||
(globalConfig.env_vars[OPENAI_COMPAT_BASE_URL] ?? "").trim().length > 0 ||
|
||||
fileSatisfiedEnvKeys.includes(OPENAI_COMPAT_BASE_URL));
|
||||
const compatibleBaseUrlValid =
|
||||
effectiveProvider !== "openai-compat" ||
|
||||
compatibleBaseUrlInherited ||
|
||||
openAiCompatibleBaseUrlError(compatibleBaseUrl) === null;
|
||||
const canSubmit =
|
||||
computeEditAgentFormValidity({
|
||||
name,
|
||||
@@ -613,9 +609,9 @@ export function AgentInstanceEditDialog({
|
||||
requiredEnvKeyMissing,
|
||||
}) &&
|
||||
providerValid &&
|
||||
compatibleBaseUrlValid &&
|
||||
!updateMutation.isPending &&
|
||||
!isAvatarUploadPending;
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const parsedParallelism = Number.parseInt(parallelism, 10);
|
||||
@@ -627,7 +623,6 @@ export function AgentInstanceEditDialog({
|
||||
// provider-backed inherit-transition carries the persona model (readiness
|
||||
// requires one) and a deliberate local model still wins.
|
||||
const normalizedModel = inheritedSubmission.model;
|
||||
|
||||
// Harness pin resolution — see resolveAgentCommandUpdate for the full
|
||||
// sentinel/pin/no-op contract, including the inherit→pin transition where
|
||||
// the prefilled command equals the original but must still be pinned.
|
||||
@@ -637,7 +632,6 @@ export function AgentInstanceEditDialog({
|
||||
originalAgentCommand: agent.agentCommand,
|
||||
agentCommandOverride: agent.agentCommandOverride ?? null,
|
||||
});
|
||||
|
||||
// Classify the effective post-submit runtime's provider capability as a
|
||||
// tri-state: "capable" persists the provider, "locked" clears it (only
|
||||
// when we KNOW it's provider-locked, e.g. Claude), "unknown" OMITS it so a
|
||||
@@ -650,7 +644,6 @@ export function AgentInstanceEditDialog({
|
||||
prospectiveRuntimeId,
|
||||
runtimeSupportsLlmProviderSelection(prospectiveRuntimeId),
|
||||
);
|
||||
|
||||
// Provider + env to persist — the shared inherited-submission snapshot
|
||||
// (same values the credential gate validates), so gate ↔ record ↔ spawn
|
||||
// all agree. See resolveInheritedRuntimeSubmission.
|
||||
@@ -726,7 +719,6 @@ export function AgentInstanceEditDialog({
|
||||
? respondToAllowlist
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = await updateMutation.mutateAsync(input);
|
||||
if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) {
|
||||
// Standalone setter (mirrors start-on-app-launch) — not part of
|
||||
@@ -766,7 +758,6 @@ export function AgentInstanceEditDialog({
|
||||
// React Query stores the error; keep dialog open and render it inline.
|
||||
}
|
||||
}
|
||||
|
||||
// Model and provider field derived state
|
||||
const normalizedConfig = configSurfaceQuery.data?.normalized;
|
||||
const modelRequired = isMissingRequiredDropdownField(
|
||||
@@ -806,7 +797,6 @@ export function AgentInstanceEditDialog({
|
||||
loading: modelDiscoveryLoading,
|
||||
status: modelDiscoveryStatus,
|
||||
});
|
||||
|
||||
// Provider field derived state
|
||||
const trimmedProvider = provider.trim();
|
||||
const hideProviderIds = React.useMemo(
|
||||
@@ -840,13 +830,11 @@ export function AgentInstanceEditDialog({
|
||||
})),
|
||||
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
|
||||
];
|
||||
|
||||
const previewLabel = name.trim() || "Agent name";
|
||||
const previewAvatarUrl = avatarUrl.trim() || null;
|
||||
const advancedFieldsTransition = shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: ADVANCED_FIELDS_MOTION_TRANSITION;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<ChooserDialogContent
|
||||
@@ -945,7 +933,6 @@ export function AgentInstanceEditDialog({
|
||||
onModeChange={setRespondTo}
|
||||
/>
|
||||
<RunOnSummarySection backend={agent.backend} />
|
||||
|
||||
{/* Provider (runtime) */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
@@ -1057,7 +1044,20 @@ export function AgentInstanceEditDialog({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{llmProviderFieldVisible &&
|
||||
effectiveProvider === "openai-compat" ? (
|
||||
<OpenAiCompatibleBaseUrlField
|
||||
disabled={updateMutation.isPending}
|
||||
inherited={compatibleBaseUrlInherited}
|
||||
onValueChange={(next) =>
|
||||
setEnvVars((prev) => ({
|
||||
...prev,
|
||||
[OPENAI_COMPAT_BASE_URL]: next,
|
||||
}))
|
||||
}
|
||||
value={compatibleBaseUrl}
|
||||
/>
|
||||
) : null}
|
||||
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
|
||||
<PersonaProviderApiKeyField
|
||||
disabled={updateMutation.isPending}
|
||||
@@ -1075,7 +1075,6 @@ export function AgentInstanceEditDialog({
|
||||
value={apiKeyValue}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
@@ -1127,7 +1126,6 @@ export function AgentInstanceEditDialog({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AgentAiDefaultsNotice
|
||||
onEditDefaults={() => setAiDefaultsOpen(true)}
|
||||
triggerRef={aiDefaultsTriggerRef}
|
||||
@@ -1136,13 +1134,11 @@ export function AgentInstanceEditDialog({
|
||||
inheritedModel={inheritedModelDefault}
|
||||
inheritedProvider={inheritedProviderDefault}
|
||||
/>
|
||||
|
||||
<AgentDefaultsDialog
|
||||
onOpenChange={setAiDefaultsOpen}
|
||||
open={aiDefaultsOpen}
|
||||
returnFocusRef={aiDefaultsTriggerRef}
|
||||
/>
|
||||
|
||||
{/* Advanced settings */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
@@ -1181,9 +1177,12 @@ export function AgentInstanceEditDialog({
|
||||
disabled={updateMutation.isPending}
|
||||
envVars={envVars}
|
||||
fileSatisfiedEnvKeys={fileSatisfiedEnvKeys}
|
||||
hiddenEnvKeys={
|
||||
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
|
||||
}
|
||||
hiddenEnvKeys={[
|
||||
...(topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []),
|
||||
...(effectiveProvider === "openai-compat"
|
||||
? [OPENAI_COMPAT_BASE_URL]
|
||||
: []),
|
||||
]}
|
||||
focusKey={
|
||||
initialFocus?.type === "env_key"
|
||||
? initialFocus.key
|
||||
@@ -1212,7 +1211,6 @@ export function AgentInstanceEditDialog({
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{updateMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { openAiCompatibleBaseUrlError } from "./OpenAiCompatibleBaseUrlField.tsx";
|
||||
|
||||
test("OpenAI-compatible base URL accepts only absolute HTTP(S) URLs", () => {
|
||||
assert.equal(openAiCompatibleBaseUrlError("http://localhost:11434/v1"), null);
|
||||
assert.equal(
|
||||
openAiCompatibleBaseUrlError(" https://models.example/v1/ "),
|
||||
null,
|
||||
);
|
||||
assert.equal(openAiCompatibleBaseUrlError(""), "Base URL is required.");
|
||||
assert.equal(
|
||||
openAiCompatibleBaseUrlError("ftp://models.example/v1"),
|
||||
"Enter a valid HTTP or HTTPS URL.",
|
||||
);
|
||||
assert.equal(
|
||||
openAiCompatibleBaseUrlError("localhost:11434/v1"),
|
||||
"Enter a valid HTTP or HTTPS URL.",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { RequiredFieldLabel } from "./agentConfigControls";
|
||||
import {
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
} from "./agentConfigOptions";
|
||||
|
||||
export const OPENAI_COMPAT_BASE_URL = "OPENAI_COMPAT_BASE_URL";
|
||||
|
||||
export function openAiCompatibleBaseUrlError(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return "Base URL is required.";
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if ((url.protocol === "http:" || url.protocol === "https:") && url.host) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the actionable validation message.
|
||||
}
|
||||
return "Enter a valid HTTP or HTTPS URL.";
|
||||
}
|
||||
|
||||
export function OpenAiCompatibleBaseUrlField({
|
||||
disabled,
|
||||
inherited = false,
|
||||
onValueChange,
|
||||
value,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
inherited?: boolean;
|
||||
onValueChange: (next: string) => void;
|
||||
value: string;
|
||||
}) {
|
||||
const error =
|
||||
inherited && value.trim().length === 0
|
||||
? null
|
||||
: openAiCompatibleBaseUrlError(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<RequiredFieldLabel htmlFor="openai-compatible-base-url" isRequired>
|
||||
Base URL
|
||||
</RequiredFieldLabel>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{OPENAI_COMPAT_BASE_URL}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
aria-invalid={error !== null}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className={cn("h-8 px-0 py-0 leading-6", PERSONA_FIELD_CONTROL_CLASS)}
|
||||
data-testid="openai-compatible-base-url"
|
||||
disabled={disabled}
|
||||
id="openai-compatible-base-url"
|
||||
onBlur={(event) =>
|
||||
onValueChange(event.target.value.trim().replace(/\/+$/, ""))
|
||||
}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
placeholder={inherited ? "Inherited" : "http://localhost:11434/v1"}
|
||||
type="url"
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
|
||||
apiKeyLabel: "OpenAI Runtime API Key",
|
||||
},
|
||||
"openai-compat": {
|
||||
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
||||
requiredEnvKeys: ["OPENAI_COMPAT_BASE_URL"],
|
||||
secretEnvVar: "OPENAI_COMPAT_API_KEY",
|
||||
apiKeyLabel: "OpenAI-compatible Runtime API Key",
|
||||
},
|
||||
|
||||
@@ -58,3 +58,30 @@ test("global defaults accept a provider key set in runtime config", () => {
|
||||
assert.equal(state.apiKeyInherited, true);
|
||||
assert.equal(state.credentialsValid, true);
|
||||
});
|
||||
|
||||
test("OpenAI-compatible defaults require the base URL but not an API key", () => {
|
||||
const missingUrl = getGlobalAgentCredentialState({
|
||||
bakedEnvKeys: [],
|
||||
envVars: {},
|
||||
provider: "openai-compat",
|
||||
runtimeFileConfig: null,
|
||||
runtimeId: "buzz-agent",
|
||||
});
|
||||
|
||||
assert.equal(missingUrl.apiKeyRequired, false);
|
||||
assert.equal(missingUrl.credentialsValid, false);
|
||||
assert.deepEqual(missingUrl.advancedRequiredEnvKeys, [
|
||||
"OPENAI_COMPAT_BASE_URL",
|
||||
]);
|
||||
|
||||
const configured = getGlobalAgentCredentialState({
|
||||
bakedEnvKeys: [],
|
||||
envVars: { OPENAI_COMPAT_BASE_URL: "http://localhost:11434/v1" },
|
||||
provider: "openai-compat",
|
||||
runtimeFileConfig: null,
|
||||
runtimeId: "buzz-agent",
|
||||
});
|
||||
|
||||
assert.equal(configured.apiKeyRequired, false);
|
||||
assert.equal(configured.credentialsValid, true);
|
||||
});
|
||||
|
||||
@@ -52,10 +52,10 @@ export function getGlobalAgentCredentialState({
|
||||
const advancedCredentialMissing = advancedRequiredEnvKeys.some(
|
||||
(key) => (envVars[key] ?? "").trim().length === 0,
|
||||
);
|
||||
const apiKeyRequired =
|
||||
apiKeyEnvVar !== null && requiredEnvKeys.includes(apiKeyEnvVar);
|
||||
const apiKeyMissing =
|
||||
apiKeyEnvVar !== null &&
|
||||
!apiKeyInherited &&
|
||||
apiKeyValue.trim().length === 0;
|
||||
apiKeyRequired && !apiKeyInherited && apiKeyValue.trim().length === 0;
|
||||
|
||||
return {
|
||||
advancedCredentialMissing,
|
||||
@@ -64,6 +64,7 @@ export function getGlobalAgentCredentialState({
|
||||
apiKeyEnvVar,
|
||||
apiKeyFileSatisfied,
|
||||
apiKeyInherited,
|
||||
apiKeyRequired,
|
||||
apiKeyValue,
|
||||
credentialsValid: !advancedCredentialMissing && !apiKeyMissing,
|
||||
};
|
||||
|
||||
@@ -94,3 +94,18 @@ test("providerApiKeyFieldState_explicitLocalEmptyShadowsRuntimeConfig", () => {
|
||||
assert.equal(state.isRequired, true);
|
||||
assert.equal(state.isInherited, false);
|
||||
});
|
||||
|
||||
test("providerApiKeyFieldState_openaiCompatKeyIsOptional", () => {
|
||||
const state = getProviderApiKeyFieldState({
|
||||
bakedEnvKeys: [],
|
||||
effectiveEnvVars: {},
|
||||
envVars: {},
|
||||
globalEnvVars: {},
|
||||
provider: "openai-compat",
|
||||
requiredEnvKeys: ["OPENAI_COMPAT_BASE_URL"],
|
||||
});
|
||||
|
||||
assert.equal(state.secretEnvVar, "OPENAI_COMPAT_API_KEY");
|
||||
assert.equal(state.isRequired, false);
|
||||
assert.deepEqual(state.advancedRequiredEnvKeys, ["OPENAI_COMPAT_BASE_URL"]);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,10 @@ export function getProviderApiKeyFieldState({
|
||||
advancedRequiredEnvKeys,
|
||||
inheritedLabel,
|
||||
isInherited: source !== null,
|
||||
isRequired: source === null && value.length === 0,
|
||||
isRequired:
|
||||
requiredEnvKeys.includes(secretEnvVar) &&
|
||||
source === null &&
|
||||
value.length === 0,
|
||||
secretEnvVar,
|
||||
value,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user