diff --git a/crates/ironclaw_gateway/static/i18n/en.js b/crates/ironclaw_gateway/static/i18n/en.js
index c64c62c3e9..4c76a0d5fd 100644
--- a/crates/ironclaw_gateway/static/i18n/en.js
+++ b/crates/ironclaw_gateway/static/i18n/en.js
@@ -455,7 +455,6 @@ I18n.register('en', {
'config.modelProviders': 'Model Providers',
'config.addProvider': '+ Add Provider',
'config.newProvider': 'New Provider',
- 'config.restartNotice': 'Changes take effect after restart.',
'config.builtin': 'built-in',
'config.useProvider': 'Use',
'config.configureProvider': 'Configure',
@@ -463,7 +462,7 @@ I18n.register('en', {
'config.configureToUse': 'Configure the API key before using this provider.',
'config.baseUrlRequired': 'Base URL is required. Please configure the provider first.',
'config.modelRequired': 'A model must be configured before using this provider.',
- 'config.providerConfigured': 'Provider "{name}" configured (restart to apply)',
+ 'config.providerConfigured': 'Provider "{name}" configured',
'config.currentModel': 'Model: {model}',
'config.providerName': 'Display Name',
'config.providerNamePlaceholder': 'My Provider',
@@ -480,9 +479,9 @@ I18n.register('en', {
'config.apiKeyFromEnv': 'Key set via environment variable',
'config.apiKeyEnter': 'Enter API key',
'config.providerModel': 'Default Model',
- 'config.providerActivated': 'Switched to {name} (restart to apply)',
- 'config.providerAdded': 'Added provider "{name}" (restart to apply)',
- 'config.providerUpdated': 'Provider "{name}" updated (restart to apply)',
+ 'config.providerActivated': 'Switched to {name}',
+ 'config.providerAdded': 'Added provider "{name}"',
+ 'config.providerUpdated': 'Provider "{name}" updated',
'config.editProvider': 'Edit Provider',
'config.providerDeleted': 'Provider deleted',
'config.confirmDeleteProvider': 'Delete provider "{id}"?',
diff --git a/crates/ironclaw_gateway/static/i18n/ko.js b/crates/ironclaw_gateway/static/i18n/ko.js
index 7ff966248d..ac12c03ba5 100644
--- a/crates/ironclaw_gateway/static/i18n/ko.js
+++ b/crates/ironclaw_gateway/static/i18n/ko.js
@@ -454,7 +454,6 @@ I18n.register('ko', {
'config.modelProviders': '모델 공급자',
'config.addProvider': '+ 공급자 추가',
'config.newProvider': '새 공급자',
- 'config.restartNotice': '변경 사항은 재시작 후 적용됩니다.',
'config.builtin': '내장',
'config.useProvider': '사용',
'config.configureProvider': '구성',
@@ -462,7 +461,7 @@ I18n.register('ko', {
'config.configureToUse': '이 공급자를 사용하기 전에 API 키를 구성하세요.',
'config.baseUrlRequired': '베이스 URL이 필요합니다. 먼저 공급자를 구성하세요.',
'config.modelRequired': '이 공급자를 사용하기 전에 모델을 구성해야 합니다.',
- 'config.providerConfigured': '공급자 "{name}"이(가) 구성되었습니다 (재시작 필요)',
+ 'config.providerConfigured': '공급자 "{name}"이(가) 구성되었습니다',
'config.currentModel': '모델: {model}',
'config.providerName': '표시 이름',
'config.providerNamePlaceholder': '내 공급자',
@@ -479,9 +478,9 @@ I18n.register('ko', {
'config.apiKeyFromEnv': '환경 변수로 키 설정됨',
'config.apiKeyEnter': 'API 키 입력',
'config.providerModel': '기본 모델',
- 'config.providerActivated': '{name}으로 전환됨 (재시작 필요)',
- 'config.providerAdded': '공급자 "{name}"이(가) 추가되었습니다 (재시작 필요)',
- 'config.providerUpdated': '공급자 "{name}"이(가) 업데이트되었습니다 (재시작 필요)',
+ 'config.providerActivated': '{name}으로 전환됨',
+ 'config.providerAdded': '공급자 "{name}"이(가) 추가되었습니다',
+ 'config.providerUpdated': '공급자 "{name}"이(가) 업데이트되었습니다',
'config.editProvider': '공급자 편집',
'config.providerDeleted': '공급자가 삭제되었습니다',
'config.confirmDeleteProvider': '공급자 "{id}"을(를) 삭제하시겠습니까?',
diff --git a/crates/ironclaw_gateway/static/i18n/zh-CN.js b/crates/ironclaw_gateway/static/i18n/zh-CN.js
index 98bd3dcc3a..e0fd493252 100644
--- a/crates/ironclaw_gateway/static/i18n/zh-CN.js
+++ b/crates/ironclaw_gateway/static/i18n/zh-CN.js
@@ -454,7 +454,6 @@ I18n.register('zh-CN', {
'config.modelProviders': '模型提供商',
'config.addProvider': '+ 添加提供商',
'config.newProvider': '新建提供商',
- 'config.restartNotice': '更改将在重启后生效。',
'config.builtin': '内置',
'config.useProvider': '使用',
'config.configureProvider': '配置',
@@ -462,7 +461,7 @@ I18n.register('zh-CN', {
'config.configureToUse': '请先配置 API 密钥后再使用此提供商。',
'config.baseUrlRequired': '需要配置基础 URL,请先配置此提供商。',
'config.modelRequired': '使用此提供商前需要配置模型。',
- 'config.providerConfigured': '提供商 "{name}" 已配置(重启后生效)',
+ 'config.providerConfigured': '提供商 "{name}" 已配置',
'config.currentModel': '模型:{model}',
'config.providerName': '显示名称',
'config.providerNamePlaceholder': '我的提供商',
@@ -479,9 +478,9 @@ I18n.register('zh-CN', {
'config.apiKeyFromEnv': '密钥已通过环境变量设置',
'config.apiKeyEnter': '输入 API 密钥',
'config.providerModel': '默认模型',
- 'config.providerActivated': '已切换到 {name}(重启后生效)',
- 'config.providerAdded': '已添加提供商 "{name}"(重启后生效)',
- 'config.providerUpdated': '提供商 "{name}" 已更新(重启后生效)',
+ 'config.providerActivated': '已切换到 {name}',
+ 'config.providerAdded': '已添加提供商 "{name}"',
+ 'config.providerUpdated': '提供商 "{name}" 已更新',
'config.editProvider': '编辑提供商',
'config.providerDeleted': '提供商已删除',
'config.confirmDeleteProvider': '确定删除提供商 "{id}"?',
diff --git a/crates/ironclaw_gateway/static/index.html b/crates/ironclaw_gateway/static/index.html
index 182319f8be..acf4cea9d5 100644
--- a/crates/ironclaw_gateway/static/index.html
+++ b/crates/ironclaw_gateway/static/index.html
@@ -437,10 +437,6 @@
Model Providers
-
- ⚠
- Changes take effect after restart.
-
diff --git a/crates/ironclaw_gateway/static/js/surfaces/config.js b/crates/ironclaw_gateway/static/js/surfaces/config.js
index a3dea15f76..704b7f18e1 100644
--- a/crates/ironclaw_gateway/static/js/surfaces/config.js
+++ b/crates/ironclaw_gateway/static/js/surfaces/config.js
@@ -248,20 +248,20 @@ function setActiveProvider(id) {
if (provider) openProviderConfigDialog(provider);
return;
}
- const modelUpdate = () => defaultModel
- ? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } })
- : apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' });
- apiFetchVoid('/api/settings/llm_backend', { method: 'PUT', body: { value: id } })
- .then(() => modelUpdate())
+ // Write backend + model atomically. Two sequential PUTs would hot-reload
+ // the chain between them with the new backend but the previous model
+ // (selected_model wins over provider defaults), leaving a mixed state
+ // if the second request fails. Import writes the set and reloads once.
+ apiFetchVoid('/api/settings/import', {
+ method: 'POST',
+ body: { settings: { llm_backend: id, selected_model: defaultModel } },
+ })
.then(() => {
_activeLlmBackend = id;
_selectedModel = defaultModel || '';
renderProviders();
loadInferenceSettings();
scrollToProviders();
- document.getElementById('config-restart-notice').style.display = 'flex';
- var llmNotice = document.getElementById('llm-restart-notice');
- if (llmNotice) llmNotice.style.display = 'flex';
showToast(I18n.t('config.providerActivated', { name: id }));
})
.catch((e) => showToast(I18n.t('error.unknown') + ': ' + e.message, 'error'));
@@ -484,11 +484,6 @@ document.getElementById('save-provider-btn').addEventListener('click', () => {
if (isActive) loadInferenceSettings();
resetProviderForm();
scrollToProviders();
- if (isActive) {
- document.getElementById('config-restart-notice').style.display = 'flex';
- var llmNotice = document.getElementById('llm-restart-notice');
- if (llmNotice) llmNotice.style.display = 'flex';
- }
showToast(I18n.t('config.providerConfigured', { name: id }));
})
.catch((e) => {
@@ -539,11 +534,6 @@ document.getElementById('save-provider-btn').addEventListener('click', () => {
if (isActive) loadInferenceSettings();
resetProviderForm();
scrollToProviders();
- if (isActive) {
- document.getElementById('config-restart-notice').style.display = 'flex';
- var llmNotice = document.getElementById('llm-restart-notice');
- if (llmNotice) llmNotice.style.display = 'flex';
- }
showToast(I18n.t('config.providerUpdated', { name }));
}).catch((e) => {
_customProviders[idx] = original;
diff --git a/crates/ironclaw_gateway/static/js/surfaces/settings.js b/crates/ironclaw_gateway/static/js/surfaces/settings.js
index 9953d91f8f..72aaa619dc 100644
--- a/crates/ironclaw_gateway/static/js/surfaces/settings.js
+++ b/crates/ironclaw_gateway/static/js/surfaces/settings.js
@@ -193,14 +193,6 @@ function loadInferenceSettings() {
title.textContent = I18n.t('cfg.group.llm');
group.appendChild(title);
- var notice = document.createElement('div');
- notice.className = 'config-notice';
- notice.id = 'llm-restart-notice';
- var restartNoticeEl = document.getElementById('config-restart-notice');
- notice.style.display = (restartNoticeEl && restartNoticeEl.style.display !== 'none') ? 'flex' : 'none';
- notice.innerHTML = '\u26A0' + escapeHtml(I18n.t('config.restartNotice')) + '';
- group.appendChild(notice);
-
var backendRow = document.createElement('div');
backendRow.className = 'settings-row';
backendRow.innerHTML =
diff --git a/crates/ironclaw_gateway/static/styles/surfaces/config.css b/crates/ironclaw_gateway/static/styles/surfaces/config.css
index 43d9b0f74d..aa0c6de4dd 100644
--- a/crates/ironclaw_gateway/static/styles/surfaces/config.css
+++ b/crates/ironclaw_gateway/static/styles/surfaces/config.css
@@ -28,19 +28,6 @@
transform: translateY(-1px);
}
-.config-notice {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: rgba(245, 166, 35, 0.1);
- border: 1px solid rgba(245, 166, 35, 0.3);
- border-radius: var(--radius);
- color: var(--warning);
- font-size: 13px;
- margin-bottom: 12px;
-}
-
.providers-list {
display: flex;
flex-direction: column;
diff --git a/src/channels/web/handlers/llm.rs b/src/channels/web/handlers/llm.rs
index f8651ef7b8..5c229c7bd0 100644
--- a/src/channels/web/handlers/llm.rs
+++ b/src/channels/web/handlers/llm.rs
@@ -310,13 +310,7 @@ async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse {
_ => {
// OpenAI-compatible, Anthropic, and NEAR AI all support GET /models.
// NEAR AI private endpoints and Anthropic need a /v1 prefix.
- let effective_base = if (req.adapter == "nearai" && is_nearai_private_endpoint(base))
- || (req.adapter == "anthropic" && !base.ends_with("/v1") && !base.contains("/v1/"))
- {
- format!("{base}/v1")
- } else {
- base.to_string()
- };
+ let effective_base = models_endpoint_base(&req.adapter, base);
let url = format!("{effective_base}/models");
let mut builder = client.get(&url);
if req.adapter == "anthropic" {
@@ -498,8 +492,19 @@ fn build_llm_providers() -> serde_json::Value {
// Shared helpers
// ---------------------------------------------------------------------------
-/// When the frontend doesn't supply an `api_key` (because it was already vaulted),
-/// look it up from the encrypted secrets store using `provider_id` + `provider_type`.
+/// When the frontend doesn't supply an `api_key` (because it was already
+/// configured), resolve it from:
+/// 1. the encrypted secrets store (per-user vaulted key), then
+/// 2. for built-in providers, the environment variable declared by the
+/// registry (e.g. `NEARAI_API_KEY`, `OPENAI_API_KEY`).
+///
+/// Fallback (2) matters because the default onboarding flow
+/// (`api_key_login()` in `llm/session.rs`) writes the key to the
+/// `NEARAI_API_KEY` env var + `~/.ironclaw/.env`, not to the secrets
+/// vault. Without the fallback, `list_models` / `test_connection`
+/// requests from the configure dialog end up with no Authorization
+/// header and the provider responds 401 even though `has_api_key`
+/// (surfaced by `build_llm_providers`) is true.
async fn resolve_api_key_from_secrets(
state: &GatewayState,
user_id: &str,
@@ -515,29 +520,77 @@ async fn resolve_api_key_from_secrets(
Some(id) => id,
None => return,
};
- let secrets = match state.secrets_store.as_ref() {
- Some(s) => s,
- None => return,
- };
- let secret_name = match provider_type.as_deref() {
- Some("custom") => crate::settings::custom_secret_name(pid),
- _ => crate::settings::builtin_secret_name(pid),
- };
- if let Ok(decrypted) = secrets.get_decrypted(user_id, &secret_name).await {
- *api_key = Some(decrypted.expose().to_string());
+
+ // 1. Encrypted secrets store (vaulted per-user key).
+ if let Some(secrets) = state.secrets_store.as_ref() {
+ let secret_name = match provider_type.as_deref() {
+ Some("custom") => crate::settings::custom_secret_name(pid),
+ _ => crate::settings::builtin_secret_name(pid),
+ };
+ if let Ok(decrypted) = secrets.get_decrypted(user_id, &secret_name).await {
+ *api_key = Some(decrypted.expose().to_string());
+ return;
+ }
+ }
+
+ // 2. Env var fallback for built-in providers.
+ if !matches!(provider_type.as_deref(), Some("custom"))
+ && let Some(env_name) = builtin_api_key_env_var(pid)
+ && let Some(val) = crate::config::helpers::env_or_override(&env_name)
+ {
+ *api_key = Some(val);
+ }
+}
+
+/// Env var name carrying the API key for a built-in provider, or `None`
+/// if the provider has no declared env var (e.g. `bedrock` uses the AWS
+/// credential chain). Mirrors the env names surfaced to the frontend by
+/// `build_llm_providers()`.
+fn builtin_api_key_env_var(provider_id: &str) -> Option {
+ // NEAR AI is a hardcoded special case and not in the registry.
+ if provider_id == "nearai" {
+ return Some("NEARAI_API_KEY".to_string());
+ }
+ crate::llm::registry::ProviderRegistry::load()
+ .find(provider_id)
+ .and_then(|def| def.api_key_env.clone())
+}
+
+/// Compute the effective base URL for a provider's `/models` endpoint.
+///
+/// Adapters that expose `/models` under `/v1` (Anthropic, NEAR AI private)
+/// need a `/v1` segment injected — but only when the operator-supplied base
+/// URL doesn't already include one. Operators commonly configure the base
+/// with or without the suffix (`https://us.private-chat-stg.near.ai` vs
+/// `https://us.private-chat-stg.near.ai/v1`) and both shapes must resolve
+/// to the same `/v1/models` URL without producing `/v1/v1/models`.
+fn models_endpoint_base(adapter: &str, base: &str) -> String {
+ let has_v1 = base.ends_with("/v1") || base.contains("/v1/");
+ let requires_v1 =
+ (adapter == "nearai" && is_nearai_private_endpoint(base)) || adapter == "anthropic";
+ if requires_v1 && !has_v1 {
+ format!("{base}/v1")
+ } else {
+ base.to_string()
}
}
/// Check if a base URL belongs to a NEAR AI private endpoint.
///
-/// Matches `private.near.ai` exactly or any subdomain of it
-/// (e.g. `us.private.near.ai`). Rejects lookalikes like
+/// Matches `private.near.ai` and `private-chat-stg.near.ai` exactly,
+/// or any subdomain of either (e.g. `us.private.near.ai`,
+/// `us.private-chat-stg.near.ai`). Rejects lookalikes like
/// `private-evil.near.ai` or `myprivate.near.ai`.
fn is_nearai_private_endpoint(base_url: &str) -> bool {
+ const PRIVATE_HOSTS: &[&str] = &["private.near.ai", "private-chat-stg.near.ai"];
url::Url::parse(base_url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
- .is_some_and(|host| host == "private.near.ai" || host.ends_with(".private.near.ai"))
+ .is_some_and(|host| {
+ PRIVATE_HOSTS
+ .iter()
+ .any(|root| host == *root || host.ends_with(&format!(".{root}")))
+ })
}
#[cfg(test)]
@@ -567,8 +620,13 @@ mod tests {
}
#[tokio::test]
+ #[allow(clippy::await_holding_lock)]
async fn test_llm_providers_returns_nearai_with_env_vars() {
- // SAFETY: test-only; tokio::test runs single-threaded by default.
+ // Serialize with other tests in this module that mutate
+ // NEARAI_* env vars (e.g.
+ // `test_llm_list_models_falls_back_to_env_api_key_for_nearai`).
+ let _env_lock = crate::config::helpers::lock_env();
+ // SAFETY: test-only; lock_env() serializes concurrent mutators.
unsafe {
std::env::set_var("NEARAI_API_KEY", "test-key-123");
std::env::set_var("NEARAI_MODEL", "test-model");
@@ -690,6 +748,20 @@ mod tests {
assert!(is_nearai_private_endpoint("https://us.private.near.ai/v1"));
}
+ #[test]
+ fn test_nearai_private_stg_exact_match() {
+ assert!(is_nearai_private_endpoint(
+ "https://private-chat-stg.near.ai/"
+ ));
+ }
+
+ #[test]
+ fn test_nearai_private_stg_subdomain() {
+ assert!(is_nearai_private_endpoint(
+ "https://us.private-chat-stg.near.ai/v1"
+ ));
+ }
+
#[test]
fn test_nearai_public_endpoint_not_private() {
assert!(!is_nearai_private_endpoint("https://cloud-api.near.ai/v1"));
@@ -709,6 +781,79 @@ mod tests {
assert!(!is_nearai_private_endpoint("https://private.evil.com/v1"));
}
+ // --- models_endpoint_base tests (URL-construction path in fetch_provider_models) ---
+ //
+ // These exercise the URL-construction gate the list-models handler uses,
+ // so a future refactor that drops the /v1 guard on the NEAR AI branch
+ // fails here — not just in the is_nearai_private_endpoint unit tests.
+
+ #[test]
+ fn test_models_endpoint_base_nearai_private_stg_adds_v1() {
+ assert_eq!(
+ models_endpoint_base("nearai", "https://us.private-chat-stg.near.ai"),
+ "https://us.private-chat-stg.near.ai/v1"
+ );
+ }
+
+ #[test]
+ fn test_models_endpoint_base_nearai_private_stg_with_v1_suffix_no_double() {
+ // Regression: operators who include /v1 in the base URL must not get
+ // /v1/v1/models (404). Before the fix, the NEAR AI branch appended
+ // /v1 unconditionally for any private host.
+ assert_eq!(
+ models_endpoint_base("nearai", "https://us.private-chat-stg.near.ai/v1"),
+ "https://us.private-chat-stg.near.ai/v1"
+ );
+ }
+
+ #[test]
+ fn test_models_endpoint_base_nearai_private_exact_with_v1_no_double() {
+ assert_eq!(
+ models_endpoint_base("nearai", "https://private.near.ai/v1"),
+ "https://private.near.ai/v1"
+ );
+ }
+
+ #[test]
+ fn test_models_endpoint_base_nearai_public_unchanged() {
+ // Public NEAR AI already embeds /v1 and doesn't need the private-host
+ // treatment at all.
+ assert_eq!(
+ models_endpoint_base("nearai", "https://cloud-api.near.ai/v1"),
+ "https://cloud-api.near.ai/v1"
+ );
+ }
+
+ #[test]
+ fn test_models_endpoint_base_anthropic_adds_v1_when_missing() {
+ assert_eq!(
+ models_endpoint_base("anthropic", "https://api.anthropic.com"),
+ "https://api.anthropic.com/v1"
+ );
+ }
+
+ #[test]
+ fn test_models_endpoint_base_anthropic_with_v1_suffix_no_double() {
+ assert_eq!(
+ models_endpoint_base("anthropic", "https://api.anthropic.com/v1"),
+ "https://api.anthropic.com/v1"
+ );
+ }
+
+ #[test]
+ fn test_models_endpoint_base_openai_compatible_unchanged() {
+ // OpenAI-compatible providers don't take the /v1 injection —
+ // operators configure the full base URL themselves.
+ assert_eq!(
+ models_endpoint_base("open_ai_completions", "https://api.openai.com/v1"),
+ "https://api.openai.com/v1"
+ );
+ assert_eq!(
+ models_endpoint_base("open_ai_completions", "https://example.test"),
+ "https://example.test"
+ );
+ }
+
// --- interpret_chat_status tests ---
#[test]
@@ -865,4 +1010,119 @@ mod tests {
.expect("response");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
+
+ // --- Env-var fallback for builtin provider API key ---
+
+ #[tokio::test]
+ #[allow(clippy::await_holding_lock)]
+ async fn test_llm_list_models_falls_back_to_env_api_key_for_nearai() {
+ // Regression: default onboarding (`api_key_login`) writes
+ // `NEARAI_API_KEY` to env, not to the secrets vault. Without the
+ // fallback in `resolve_api_key_from_secrets`, the configure dialog's
+ // "Fetch available models" button sends no `api_key` (UI shows
+ // "Key configured"), the handler skips Authorization, and NEAR AI
+ // returns 401.
+ use std::sync::{Arc, Mutex};
+
+ use axum::body::Body;
+ use tower::ServiceExt;
+
+ // Serialize against other tests in this module that mutate
+ // NEARAI_API_KEY (e.g. `test_llm_providers_returns_nearai_with_env_vars`).
+ // `std::env::set_var` is UB under concurrent access; the codebase uses
+ // `lock_env()` as the canonical mutex for this hazard.
+ let _env_lock = crate::config::helpers::lock_env();
+
+ let captured_auth: Arc>> = Arc::new(Mutex::new(None));
+ let captured_auth_clone = Arc::clone(&captured_auth);
+ let mock = axum::Router::new().route(
+ "/models",
+ axum::routing::get(move |headers: axum::http::HeaderMap| {
+ let auth = headers
+ .get("authorization")
+ .and_then(|v| v.to_str().ok())
+ .map(String::from);
+ *captured_auth_clone.lock().unwrap() = auth;
+ async move {
+ axum::Json(serde_json::json!({
+ "data": [{"id": "mock-model"}]
+ }))
+ }
+ }),
+ );
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind mock server");
+ let addr = listener.local_addr().expect("mock server addr");
+ tokio::spawn(async move {
+ let _ = axum::serve(listener, mock).await;
+ });
+
+ // SAFETY: test-only; tokio::test runs single-threaded by default.
+ // Mirrors the existing env-set pattern in this file (see
+ // `test_llm_providers_returns_nearai_with_env_vars`).
+ //
+ // `NO_PROXY` is set so reqwest bypasses any developer-machine
+ // system proxy for the 127.0.0.1 mock server. CI runners
+ // without a proxy ignore it; without it, a local HTTP proxy
+ // (e.g. ClashX on macOS) returns 502 before reaching the mock.
+ let test_key = "test-env-api-key-nearai";
+ unsafe {
+ std::env::set_var("NEARAI_API_KEY", test_key);
+ std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
+ }
+
+ let state = test_gateway_state(None);
+ let app = Router::new()
+ .route("/api/llm/list_models", post(llm_list_models_handler))
+ .with_state(state);
+
+ let req_body = serde_json::json!({
+ "adapter": "nearai",
+ "base_url": format!("http://{addr}"),
+ "provider_id": "nearai",
+ "provider_type": "builtin",
+ // intentionally no api_key — models what the UI sends when the
+ // key is "already configured" via NEARAI_API_KEY.
+ });
+ let mut req = axum::http::Request::builder()
+ .method("POST")
+ .uri("/api/llm/list_models")
+ .header("content-type", "application/json")
+ .body(Body::from(req_body.to_string()))
+ .expect("request");
+ req.extensions_mut().insert(UserIdentity {
+ user_id: "admin-user".to_string(),
+ role: "admin".to_string(),
+ workspace_read_scopes: Vec::new(),
+ });
+
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ let status = resp.status();
+ let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
+ .await
+ .expect("body");
+
+ unsafe {
+ std::env::remove_var("NEARAI_API_KEY");
+ std::env::remove_var("NO_PROXY");
+ }
+
+ assert_eq!(status, StatusCode::OK);
+ let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json response");
+ assert_eq!(
+ parsed["ok"],
+ serde_json::Value::Bool(true),
+ "handler must report success: {parsed}"
+ );
+
+ let auth_header = captured_auth.lock().unwrap().clone();
+ assert_eq!(
+ auth_header.as_deref(),
+ Some(format!("Bearer {test_key}").as_str()),
+ "handler must forward NEARAI_API_KEY env var as Authorization header"
+ );
+ }
}