Polish provider setup and local-first settings

This commit is contained in:
2026-08-21 20:06:50 -05:00
parent be1dbb600a
commit 1f1d0737a9
21 changed files with 388 additions and 523 deletions
+62 -48
View File
@@ -7,10 +7,13 @@
use aws_config::BehaviorVersion;
use aws_sdk_bedrock::Client;
use aws_sdk_bedrockruntime::config::Region;
use futures::{stream, StreamExt};
use super::client::{BedrockClientConfig, BedrockError};
use crate::settings::ai::BedrockModelConfig;
const AVAILABILITY_CHECK_CONCURRENCY: usize = 8;
pub async fn discover_available_models(
config: BedrockClientConfig,
) -> Result<Vec<BedrockModelConfig>, String> {
@@ -24,61 +27,72 @@ pub async fn discover_available_models(
.await
.map_err(|error| format!("Could not list AWS Bedrock foundation models: {error}"))?;
let mut models = Vec::new();
for summary in catalog.model_summaries() {
let model_id = summary.model_id();
let availability = match client
.get_foundation_model_availability()
.model_id(model_id)
.send()
.await
{
Ok(availability) => availability,
Err(error) => {
log::debug!(
"[bedrock] Availability check failed for {model_id}; excluding model: {error}"
);
continue;
}
};
// AWS exposes agreement/authorization state only through an individual
// GetFoundationModelAvailability call. Keep using that control-plane API,
// but bound the independent checks so a large catalog does not serialize
// startup discovery one model at a time.
let checks = catalog.model_summaries().iter().cloned().map(|summary| {
let client = client.clone();
async move {
let model_id = summary.model_id();
let availability = match client
.get_foundation_model_availability()
.model_id(model_id)
.send()
.await
{
Ok(availability) => availability,
Err(error) => {
log::debug!(
"[bedrock] Availability check failed for {model_id}; excluding model: {error}"
);
return None;
}
};
if !model_availability_is_usable(
availability
.agreement_availability()
.map(|agreement| agreement.status().as_str()),
availability.authorization_status().as_str(),
availability.entitlement_availability().as_str(),
availability.region_availability().as_str(),
) {
log::debug!(
"[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}",
if !model_availability_is_usable(
availability
.agreement_availability()
.map(|agreement| agreement.status().as_str())
.unwrap_or("MISSING"),
.map(|agreement| agreement.status().as_str()),
availability.authorization_status().as_str(),
availability.entitlement_availability().as_str(),
availability.region_availability().as_str(),
);
continue;
) {
log::debug!(
"[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}",
availability
.agreement_availability()
.map(|agreement| agreement.status().as_str())
.unwrap_or("MISSING"),
availability.authorization_status().as_str(),
availability.entitlement_availability().as_str(),
availability.region_availability().as_str(),
);
return None;
}
let display_name = summary
.model_name()
.map(str::to_owned)
.unwrap_or_else(|| prettify_model_id(model_id));
let vision_supported = summary
.input_modalities()
.iter()
.any(|modality| modality.as_str() == "IMAGE");
Some(BedrockModelConfig {
model_id: model_id.to_owned(),
display_name,
vision_supported,
use_rig: false,
})
}
let display_name = summary
.model_name()
.map(str::to_owned)
.unwrap_or_else(|| prettify_model_id(model_id));
let vision_supported = summary
.input_modalities()
.iter()
.any(|modality| modality.as_str() == "IMAGE");
models.push(BedrockModelConfig {
model_id: model_id.to_owned(),
display_name,
vision_supported,
use_rig: false,
});
}
});
let mut models = stream::iter(checks)
.buffer_unordered(AVAILABILITY_CHECK_CONCURRENCY)
.filter_map(futures::future::ready)
.collect::<Vec<_>>()
.await;
models.sort_by(|left, right| left.display_name.cmp(&right.display_name));
if models.is_empty() {
@@ -166,26 +166,24 @@ impl ResponseStream {
// Check if this specific model has an OpenAI-compatible routing entry.
// This allows OpenAI/LiteLLM models to coexist with Bedrock models —
// only models fetched from the OpenAI endpoint route through it.
if *settings.openai_enabled.value() {
let llm_prefs = LLMPreferences::as_ref(ctx);
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig {
kind: client_config.kind,
base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config
.model
.clone()
.or_else(|| Some(model_id.to_string())),
reasoning_effort: client_config.reasoning_effort.clone(),
max_input_tokens: client_config.max_input_tokens,
max_output_tokens: client_config.max_output_tokens,
use_rig: client_config.use_rig,
supports_system_messages: client_config.supports_system_messages,
});
}
let llm_prefs = LLMPreferences::as_ref(ctx);
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig {
kind: client_config.kind,
base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config
.model
.clone()
.or_else(|| Some(model_id.to_string())),
reasoning_effort: client_config.reasoning_effort.clone(),
max_input_tokens: client_config.max_input_tokens,
max_output_tokens: client_config.max_output_tokens,
use_rig: client_config.use_rig,
supports_system_messages: client_config.supports_system_messages,
});
}
// Fall back to Bedrock
+10 -1
View File
@@ -46,8 +46,17 @@ pub(crate) struct ChatGPTAuthModel {
impl ChatGPTAuthModel {
pub(crate) fn new() -> Self {
let state = match load_or_import_auth_credentials() {
Ok(_) => ChatGPTAuthState::Connected,
Err(error) => {
log::debug!(
"[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}"
);
ChatGPTAuthState::NotConnected
}
};
Self {
state: ChatGPTAuthState::NotConnected,
state,
pending_code_verifier: None,
pending_state: None,
}
+18 -20
View File
@@ -160,26 +160,24 @@ impl CrosscheckReviewer {
let settings = AISettings::as_ref(ctx);
// Check if this model has an OpenAI-compatible routing entry
if *settings.openai_enabled.value() {
let llm_prefs = LLMPreferences::as_ref(ctx);
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig {
kind: client_config.kind,
base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config
.model
.clone()
.or_else(|| Some(model_id.to_string())),
reasoning_effort: client_config.reasoning_effort.clone(),
max_input_tokens: client_config.max_input_tokens,
max_output_tokens: Some(REVIEWER_MAX_OUTPUT_TOKENS),
use_rig: client_config.use_rig,
supports_system_messages: client_config.supports_system_messages,
});
}
let llm_prefs = LLMPreferences::as_ref(ctx);
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig {
kind: client_config.kind,
base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config
.model
.clone()
.or_else(|| Some(model_id.to_string())),
reasoning_effort: client_config.reasoning_effort.clone(),
max_input_tokens: client_config.max_input_tokens,
max_output_tokens: Some(REVIEWER_MAX_OUTPUT_TOKENS),
use_rig: client_config.use_rig,
supports_system_messages: client_config.supports_system_messages,
});
}
// Fall back to Bedrock via external config
+56 -18
View File
@@ -634,6 +634,27 @@ impl LLMPreferences {
}
});
#[cfg(not(target_family = "wasm"))]
if ctx
.try_get_singleton_model_as_ref::<super::chatgpt_auth::ChatGPTAuthModel>()
.is_some()
{
ctx.subscribe_to_model(
&super::chatgpt_auth::ChatGPTAuthModel::handle(ctx),
|me, _, event, ctx| {
if matches!(
event,
super::chatgpt_auth::ChatGPTAuthModelEvent::StateChanged
) && matches!(
super::chatgpt_auth::ChatGPTAuthModel::as_ref(ctx).state(),
super::chatgpt_auth::ChatGPTAuthState::Connected
) {
me.refresh_chatgpt_subscription_models(ctx);
}
},
);
}
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| {
if let UserWorkspacesEvent::TeamsChanged = event {
me.sanitize_disabled_custom_model_preferences(ctx);
@@ -735,6 +756,10 @@ impl LLMPreferences {
#[cfg(not(target_family = "wasm"))]
{
// Make the persisted Bedrock catalog available immediately. Agreement
// revalidation stays in the background and replaces this cache when it
// completes, so startup never waits on the per-model AWS checks.
me.inject_bedrock_models(ctx);
me.refresh_bedrock_models(ctx);
me.inject_openai_models(ctx);
me.ensure_default_model_present();
@@ -978,7 +1003,7 @@ impl LLMPreferences {
let settings = AISettings::as_ref(ctx);
self.inject_acp_models(ctx);
if !*settings.openai_enabled.value() {
if !settings.is_openai_provider_enabled() {
return;
}
@@ -1163,7 +1188,7 @@ impl LLMPreferences {
},
)]),
discount_percentage: None,
context_window: openai_model_context_window(model),
context_window: openai_model_context_window(model, provider_kind),
};
self.models_by_feature
.agent_mode
@@ -1447,7 +1472,7 @@ impl LLMPreferences {
#[cfg(not(target_family = "wasm"))]
pub fn fetch_openai_models_from_endpoint(&mut self, ctx: &mut ModelContext<Self>) {
let settings = AISettings::as_ref(ctx);
if !*settings.openai_enabled.value() {
if !settings.is_openai_provider_enabled() {
return;
}
@@ -1509,7 +1534,7 @@ impl LLMPreferences {
ctx: &mut ModelContext<Self>,
) {
let settings = AISettings::as_ref(ctx);
if !*settings.openai_enabled.value() {
if !settings.is_openai_provider_enabled() {
return;
}
@@ -1716,7 +1741,7 @@ impl LLMPreferences {
}
let settings = AISettings::as_ref(ctx);
if !*settings.openai_enabled.value()
if !settings.is_openai_provider_enabled()
|| !settings.openai_providers.value().iter().any(|provider| {
provider.enabled && provider.kind == OpenAIProviderKind::ChatGPTSubscription
})
@@ -2682,13 +2707,21 @@ fn openai_model_variant_id(model_id: &str, reasoning_effort: &str) -> String {
}
#[cfg(not(target_family = "wasm"))]
fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow {
let context_size = openai_model_context_size(model);
fn openai_model_context_window(
model: &OpenAIModelConfig,
provider_kind: OpenAIProviderKind,
) -> LLMContextWindow {
let default_context_size = openai_model_context_size(model);
let max_context_size = if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
model.context_size.max(default_context_size)
} else {
default_context_size
};
LLMContextWindow {
is_configurable: false,
min: context_size,
max: context_size,
default_max: context_size,
is_configurable: max_context_size > default_context_size,
min: default_context_size,
max: max_context_size,
default_max: default_context_size,
}
}
@@ -2822,18 +2855,23 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIMod
return None;
}
let context_size = u32_from_json_any(model, &["context_window", "max_context_window"])
let default_context_size = u32_from_json_any(model, &["context_window"])
.or_else(|| u32_from_json_any(model, &["max_context_window"]))
.unwrap_or(DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE);
let max_context_size = u32_from_json_any(model, &["max_context_window"])
.unwrap_or(default_context_size)
.max(default_context_size);
let effective_context_percent = model["effective_context_window_percent"]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.filter(|value| (1..=100).contains(value))
.unwrap_or(100);
let max_input_tokens = Some(
context_size
.checked_mul(effective_context_percent)
.map(|tokens| tokens / 100)
.unwrap_or(context_size),
);
let effective_context_size = |context_size: u32| {
u32::try_from(u64::from(context_size) * u64::from(effective_context_percent) / 100)
.unwrap_or(context_size)
};
let max_input_tokens = Some(effective_context_size(default_context_size));
let context_size = effective_context_size(max_context_size);
let vision_supported = model["input_modalities"]
.as_array()
+19 -3
View File
@@ -760,7 +760,7 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
"display_name": "GPT-5.6-Sol",
"visibility": "list",
"context_window": 272000,
"max_context_window": 272000,
"max_context_window": 872000,
"effective_context_window_percent": 95,
"input_modalities": ["text", "image"],
"supported_reasoning_levels": [
@@ -795,7 +795,7 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
assert_eq!(models[0].model_id, "gpt-5.6-sol");
assert_eq!(models[0].display_name, "GPT-5.6-Sol");
assert!(models[0].vision_supported);
assert_eq!(models[0].context_size, 272_000);
assert_eq!(models[0].context_size, 828_400);
assert_eq!(models[0].max_input_tokens, Some(258_400));
assert_eq!(models[0].reasoning_efforts, ["low", "xhigh", "ultra"]);
assert!(models[0].use_rig);
@@ -804,8 +804,24 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
assert_eq!(models[1].model_id, "gpt-text-only");
assert!(!models[1].vision_supported);
assert_eq!(models[1].context_size, 128_000);
assert_eq!(models[1].context_size, 115_200);
assert_eq!(models[1].max_input_tokens, Some(115_200));
let configurable = openai_model_context_window(
&models[0],
crate::settings::OpenAIProviderKind::ChatGPTSubscription,
);
assert!(configurable.is_configurable);
assert_eq!(configurable.min, 258_400);
assert_eq!(configurable.default_max, 258_400);
assert_eq!(configurable.max, 828_400);
let fixed =
openai_model_context_window(&models[0], crate::settings::OpenAIProviderKind::LiteLLM);
assert!(!fixed.is_configurable);
assert_eq!(fixed.min, 258_400);
assert_eq!(fixed.default_max, 258_400);
assert_eq!(fixed.max, 258_400);
}
#[test]