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

-
Loading...
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" + ); + } }