mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
fix(web): resolve empty “Fetch available models” result for NEAR AI in settings (#2890)
* fix(web): match subdomains of private-chat-stg.near.ai as NEAR AI private endpoint
* fix(web): remove LLM provider restart notices now that hot-reload is supported
LLM provider changes (switch/add/configure/update) now apply without a
restart, so the inline "Changes take effect after restart" banner in the
LLM Providers section, the mirrored banner in the Inference settings
panel, and the "(restart to apply)" suffix on the provider toasts are
all stale. Drops the HTML banner, the dynamic mirror in settings.js,
the three show-calls in config.js, the now-unused .config-notice CSS,
the config.restartNotice i18n key, and the suffix from providerConfigured
/ providerActivated / providerAdded / providerUpdated across en / zh-CN
/ ko. RESTART_REQUIRED_KEYS (embeddings, tunnel, gateway) is untouched —
those still require a restart.
* fix(web): avoid /v1/v1/models for NEAR AI private hosts with /v1 suffix
fetch_provider_models unconditionally appended /v1 for any NEAR AI
private host, so operators configuring a base URL that already ends in
/v1 (e.g. https://us.private-chat-stg.near.ai/v1) got /v1/v1/models and
404s from "Fetch available models". The Anthropic branch already had
the guard; the NEAR AI branch did not.
Extract models_endpoint_base(adapter, base) as a pure helper covering
both adapters, and swap the inline logic in fetch_provider_models for a
single call. Adds caller-level regression tests around the URL
construction path per .claude/rules/testing.md — the previous helper-
only tests on is_nearai_private_endpoint would have stayed green
through this bug.
* chore: minor
* fix(web): atomically switch llm_backend + selected_model on provider activation
setActiveProvider() was issuing two sequential PUTs — /api/settings/llm_backend
then /api/settings/selected_model. The settings handler hot-reloads the LLM
provider chain after each write, and config/llm.rs gives selected_model
precedence over provider defaults/overrides, so the first reload rebuilds the
chain with the new backend but the previous provider's model. If the second
request then fails, the instance stays stuck in that mixed state while the
success toast has already fired.
Route both writes through /api/settings/import instead: set_all_settings
commits the pair in one transaction and triggers a single reload, with
snapshot-based rollback of every key if the resulting chain fails to build.
Raised on the restart-notice removal PR (27c70552) — before that commit the
inline banner at least hinted the switch wasn't fully live; now that the
banner is gone, the atomicity gap is the only thing standing between the
toast and reality.
* fix(web): fall back to env var for builtin provider API keys
`resolve_api_key_from_secrets` previously only consulted the encrypted
secrets store, so the "Fetch available models" and "Test" buttons in
the Configure dialog sent no Authorization header when the UI showed
"Key configured (leave blank to keep)" — the provider then responded
401 even though chat worked.
Default IronClaw onboarding (`api_key_login()` in `llm/session.rs`)
writes the key to `NEARAI_API_KEY` + `~/.ironclaw/.env`, not to the
vault. The secrets-store-only lookup missed that path entirely. Add
an env-var fallback that resolves the right env name per provider —
`NEARAI_API_KEY` for NEAR AI, `ProviderDefinition::api_key_env` for
registry providers — so the configure dialog matches what the chat
pipeline already sees. Only applies to builtin providers; custom
providers have no declared env var and are unchanged.
Adds a caller-level regression test driving `llm_list_models_handler`
against a local mock server and asserting the forwarded
`Authorization: Bearer <key>` matches the env var. Also takes
`config::helpers::lock_env()` in both NEARAI env-mutating tests so
the module no longer flakes under parallel test execution.
This commit is contained in:
@@ -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}"?',
|
||||
|
||||
@@ -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}"을(를) 삭제하시겠습니까?',
|
||||
|
||||
@@ -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}"?',
|
||||
|
||||
@@ -437,10 +437,6 @@
|
||||
<h3 data-i18n="config.modelProviders">Model Providers</h3>
|
||||
<button id="add-provider-btn" class="btn-add-provider" data-i18n="config.addProvider">+ Add Provider</button>
|
||||
</div>
|
||||
<div class="config-notice" id="config-restart-notice" style="display:none">
|
||||
<span>⚠</span>
|
||||
<span data-i18n="config.restartNotice">Changes take effect after restart.</span>
|
||||
</div>
|
||||
<div id="providers-list" class="providers-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '<span>\u26A0</span><span>' + escapeHtml(I18n.t('config.restartNotice')) + '</span>';
|
||||
group.appendChild(notice);
|
||||
|
||||
var backendRow = document.createElement('div');
|
||||
backendRow.className = 'settings-row';
|
||||
backendRow.innerHTML =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
// 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<Mutex<Option<String>>> = 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::<axum::http::Request<Body>>::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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user