Allow creating custom providers during sign-in (#60186)

## Why

Custom-provider sign-in previously required a saved gateway configuration and stopped at an error when none existed.

## What changed

- Add “Create a custom provider or gateway” to the sign-in picker, reusing the settings form and provider-write handling.
- Return to the picker after saving, highlight the new provider, and require explicit selection before setting it as the default and completing sign-in.
- Preserve entered fields after validation failures and keep the focused field or Save action visible in short terminals.
- Use consistent custom-provider and gateway wording across sign-in, settings, and validation messages.

## Testing

Add onboarding tests and snapshots for an empty picker, creation, validation retries, short-terminal rendering, and explicit default selection. Extend app-server coverage for rejecting the reserved `ollama-chat` provider ID.

GitOrigin-RevId: 339ac5807c5eecdae08df4dbd89874ea7c764d35
This commit is contained in:
alexsong-oai
2026-11-07 23:51:58 +00:00
committed by copyberry
parent 8044c6867c
commit d960b36694
26 changed files with 551 additions and 157 deletions
@@ -91,7 +91,7 @@ fn provider_edits(
.is_some_and(|id| builtins.contains_key(id) || id == LEGACY_OLLAMA_CHAT_PROVIDER_ID)
{
return Err(invalid(
"A custom gateway cannot use a built-in provider ID.",
"A custom provider or gateway cannot use a built-in provider ID.",
));
}
let existing = target.and_then(|id| configured?.get(id));
@@ -157,6 +157,11 @@ async fn custom_provider_edits_preserve_other_fields_and_respect_policy() -> Res
"configValidationError",
),
("basic", json!({"envKey": null}), "configValidationError"),
(
"ollama-chat",
json!({"name": "Renamed"}),
"configValidationError",
),
("ollama", json!({}), "configValidationError"),
("gcp", json!({}), "configValidationError"),
] {
@@ -171,6 +176,12 @@ async fn custom_provider_edits_preserve_other_fields_and_respect_policy() -> Res
let error = server
.read_stream_until_error_message(RequestId::Integer(edit))
.await?;
if id == "ollama-chat" {
insta::assert_snapshot!(
error.error.message,
@"A custom provider or gateway cannot use a built-in provider ID."
);
}
assert_eq!(
error.error.data,
Some(json!({"config_write_error_code": error_code}))
+4 -1
View File
@@ -24,6 +24,8 @@ use std::sync::Arc;
use tokio::sync::watch;
mod editor;
pub(crate) use editor::Draft;
pub(crate) use editor::ProviderForm;
mod fields;
pub(crate) mod rpc;
mod write;
@@ -32,6 +34,7 @@ use fields::provider_fields;
const LOADING_VIEW: &str = "model_provider_loading";
pub(super) const LIST_VIEW: &str = "model_provider_list";
pub(crate) const CREATE_CUSTOM_PROVIDER: &str = "Create a custom provider or gateway";
#[derive(Debug)]
pub(crate) enum Event {
@@ -314,7 +317,7 @@ fn list(
})
.collect();
items.push(SelectionItem {
name: "Add custom provider".into(),
name: CREATE_CUSTOM_PROVIDER.into(),
footer_hint: Some(crate::bottom_pane::popup_consts::picker_hint_line_for_keymap(keymap)),
actions: vec![Box::new({
let catalog = Arc::clone(&catalog);
+135 -60
View File
@@ -1,10 +1,10 @@
//! Single-screen provider form. Validation preserves the draft and each field owns its editing history.
use super::CREATE_CUSTOM_PROVIDER;
use super::Field;
use crate::app::App;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::TextArea;
use crate::bottom_pane::ViewCompletion;
use crate::key_hint;
use crate::keymap::KeymapContextSet;
@@ -25,7 +25,6 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::sync::MutexGuard;
mod form;
pub(crate) use form::Draft;
@@ -38,40 +37,62 @@ pub(crate) fn view(
error: Option<String>,
on_save: impl Fn(Draft, ModelProviderConfigWriteParams) + Send + 'static,
) -> Box<dyn BottomPaneView> {
let mut inputs = Vec::new();
for (field, original) in draft.fields() {
let input = ProviderInput::from(draft.value(field, original));
let form = ProviderForm::new(draft, error);
for (_, input) in &form.inputs {
let mut editor = input.editor();
editor.set_keymap_bindings(&app.keymap);
if app.chat_widget.composer_is_vim_enabled() {
editor.set_vim_enabled(/*enabled*/ true);
editor.enter_vim_insert_mode();
}
drop(editor);
inputs.push((field, input));
}
Box::new(Editor {
inputs,
draft,
form,
on_save: Box::new(on_save),
error,
completion: None,
})
}
struct Editor {
inputs: Vec<(Field, ProviderInput)>,
draft: Draft,
form: ProviderForm,
on_save: Box<dyn Fn(Draft, ModelProviderConfigWriteParams) + Send>,
error: Option<String>,
completion: Option<ViewCompletion>,
}
impl Editor {
fn active_input(&self) -> Option<MutexGuard<'_, TextArea>> {
pub(crate) struct ProviderForm {
inputs: Vec<(Field, ProviderInput)>,
draft: Draft,
pub(crate) error: Option<String>,
}
struct FormLayout {
paragraph: Paragraph<'static>,
focus_row: u16,
cursor: Option<(u16, u16)>,
}
impl ProviderForm {
pub(crate) fn new(draft: Draft, error: Option<String>) -> Self {
let inputs = draft
.fields()
.into_iter()
.map(|(field, original)| (field, ProviderInput::from(draft.value(field, original))))
.collect();
Self {
inputs,
draft,
error,
}
}
pub(crate) fn active_input_mut(&mut self) -> Option<&mut ProviderInput> {
self.inputs
.get(self.draft.selected)
.map(|(_, input)| input.editor())
.get_mut(self.draft.selected)
.map(|(_, input)| input)
}
pub(crate) fn active_input(&self) -> Option<&ProviderInput> {
self.inputs.get(self.draft.selected).map(|(_, input)| input)
}
fn changes(&self) -> impl Iterator<Item = (Field, String)> {
@@ -87,12 +108,43 @@ impl Editor {
})
}
fn save(&mut self) {
self.draft.changes = self.changes().collect();
match self.draft.patch() {
Ok(params) => (self.on_save)(self.draft.clone(), params),
Err(error) => self.error = Some(error.to_owned()),
pub(crate) fn input(&mut self, key: KeyEvent) -> Option<ModelProviderConfigWriteParams> {
if move_provider_field(key, &mut self.draft.selected, self.inputs.len() + 1) {
return None;
}
// Holding Enter on a field must not carry its activation onto Save.
if key.kind == KeyEventKind::Press && key_hint::plain(KeyCode::Enter).is_press(key) {
if self.draft.selected == self.inputs.len() {
self.draft.changes = self.changes().collect();
match self.draft.patch() {
Ok(params) => return Some(params),
Err(message) => self.error = Some(message.into()),
}
} else {
self.draft.selected += 1;
}
return None;
}
if key_hint::ctrl(KeyCode::Char(' ')).is_press(key)
&& let Some(suggestion) = self.suggestion()
{
let mut editor = self.inputs[self.draft.selected].1.editor();
let suffix = &suggestion[editor.cursor()..];
editor.insert_str(suffix);
return None;
}
if let Some((_, input)) = self.inputs.get_mut(self.draft.selected) {
input.input(key);
}
None
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
self.active_input().is_some_and(|input| {
input
.editor()
.should_handle_vim_insert_escape(KeyCode::Esc.into())
})
}
fn suggestion(&self) -> Option<&str> {
@@ -111,15 +163,16 @@ impl Editor {
.find(|name| *name != prefix && name.starts_with(prefix))
}
fn layout(&self, width: u16) -> (Paragraph<'static>, Option<(u16, u16)>) {
fn layout(&self, width: u16) -> FormLayout {
let title = self.draft.provider.as_ref().map_or_else(
|| "Add custom provider".to_owned(),
|| CREATE_CUSTOM_PROVIDER.to_owned(),
|p| format!("Edit {}", p.name),
);
let wrap_width = usize::from(width.max(1));
let mut lines = word_wrap_lines([Line::from(title.bold())], wrap_width);
lines.push(Line::default());
let mut cursor = None;
let mut focus_row = 0;
let key_needs_url = !self.draft.required(Field::Url)
&& self
.changes()
@@ -157,6 +210,7 @@ impl Editor {
let x = line.width() as u16;
let area = Rect::new(x, lines.len() as u16, width.saturating_sub(x), 1);
if focused {
focus_row = area.y;
cursor = input.editor().cursor_pos(area);
}
let value = input.line("", example, display, area.width);
@@ -164,10 +218,14 @@ impl Editor {
lines.push(truncate_line_to_width(line, usize::from(width)));
}
lines.push(if self.draft.selected == self.inputs.len() {
focus_row = lines.len() as u16;
"› Save".bold().into()
} else {
" Save".into()
});
if let Some(error) = &self.error {
lines.push(error.clone().red().into());
}
lines.push(Line::default());
let has = |field| self.inputs.iter().any(|(key, _)| *key == field);
if has(Field::Name) {
@@ -184,9 +242,6 @@ impl Editor {
format!(" complete {suggestion}").dim(),
]));
}
if let Some(error) = &self.error {
lines.push(error.clone().red().into());
}
lines.push(Line::from(vec![
key_hint::plain(KeyCode::Tab).into(),
" move · ".dim(),
@@ -200,28 +255,53 @@ impl Editor {
}
.dim(),
]));
(Paragraph::new(lines).wrap(Wrap { trim: false }), cursor)
FormLayout {
paragraph: Paragraph::new(lines).wrap(Wrap { trim: false }),
focus_row,
cursor,
}
}
fn visible_layout(&self, area: Rect) -> (Paragraph<'static>, Option<(u16, u16)>) {
let FormLayout {
paragraph,
cursor,
focus_row,
} = self.layout(area.width);
// Show as much feedback below Save as fits while keeping the active action visible.
let top = if self.draft.selected == self.inputs.len() {
paragraph
.desired_height(area.width)
.saturating_sub(area.height)
.min(focus_row)
} else {
focus_row.saturating_add(1).saturating_sub(area.height)
};
(
paragraph.scroll((top, 0)),
cursor.map(|(x, y)| (x, y.saturating_sub(top))),
)
}
}
impl Renderable for Editor {
impl Renderable for ProviderForm {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.layout(area.width).0.render(area, buf);
self.visible_layout(area).0.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.layout(width).0.desired_height(width)
self.layout(width).paragraph.desired_height(width)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let (x, y) = self.layout(area.width).1?;
let (x, y) = self.visible_layout(area).1?;
(y < area.height).then(|| (area.x.saturating_add(x), area.y.saturating_add(y)))
}
fn cursor_style(&self, _area: Rect) -> SetCursorStyle {
if self
.active_input()
.is_some_and(|editor| editor.uses_vim_insert_cursor())
.is_some_and(|input| input.editor().uses_vim_insert_cursor())
{
SetCursorStyle::SteadyBar
} else {
@@ -230,39 +310,34 @@ impl Renderable for Editor {
}
}
impl Renderable for Editor {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.form.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.form.desired_height(width)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.form.cursor_pos(area)
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
self.form.cursor_style(area)
}
}
impl BottomPaneView for Editor {
fn view_id(&self) -> Option<&'static str> {
Some(VIEW_ID)
}
fn handle_key_event(&mut self, key: KeyEvent) {
if move_provider_field(key, &mut self.draft.selected, self.inputs.len() + 1) {
return;
}
// Holding Enter on a field must not carry its activation onto Save.
if key.kind == KeyEventKind::Press && key_hint::plain(KeyCode::Enter).is_press(key) {
if self.draft.selected == self.inputs.len() {
self.save();
} else {
self.draft.selected += 1;
}
return;
}
if key_hint::ctrl(KeyCode::Char(' ')).is_press(key)
&& let Some(suggestion) = self.suggestion()
{
let mut editor = self.inputs[self.draft.selected].1.editor();
let suffix = &suggestion[editor.cursor()..];
editor.insert_str(suffix);
return;
}
if let Some((_, input)) = self.inputs.get_mut(self.draft.selected) {
input.input(key);
if let Some(params) = self.form.input(key) {
(self.on_save)(self.form.draft.clone(), params);
}
}
fn handle_paste(&mut self, pasted: String) -> bool {
if let Some((_, input)) = self.inputs.get_mut(self.draft.selected) {
if let Some(input) = self.form.active_input_mut() {
input.paste(&pasted);
return true;
}
@@ -270,14 +345,14 @@ impl BottomPaneView for Editor {
}
fn keymap_contexts(&self) -> KeymapContextSet {
self.active_input()
.map(|editor| KeymapContextSet::new(editor.keymap_context()))
self.form
.active_input()
.map(|input| KeymapContextSet::new(input.editor().keymap_context()))
.unwrap_or_default()
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
self.active_input()
.is_some_and(|editor| editor.should_handle_vim_insert_escape(KeyCode::Esc.into()))
self.form.prefer_esc_to_handle_key_event()
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
@@ -135,10 +135,10 @@ async fn entering_a_key_for_a_legacy_gateway_prompts_for_its_address() {
Base URL* https://gateway.example/v1
API key variable $TEAM_KEY
› Save
Enter a base URL to use this API key variable.
* Required
Enter the environment variable name on the app server.
Enter a base URL to use this API key variable.
tab move · enter next/save · esc back
");
}
+45 -1
View File
@@ -1,4 +1,4 @@
//! List providers and select a default from either settings or sign-in.
//! Provider catalog and config writes used by the settings panel and sign-in wizard.
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
@@ -6,11 +6,23 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::ModelProviderConfigWriteParams;
use codex_app_server_protocol::ModelProviderConfigWriteResponse;
use codex_app_server_protocol::ModelProviderListParams;
use codex_app_server_protocol::ModelProviderListResponse;
use codex_app_server_protocol::ModelProviderSummary;
use codex_app_server_protocol::ProviderSettingsPatch;
use codex_app_server_protocol::RequestId;
use uuid::Uuid;
pub(crate) const UNCONFIRMED_CREATE: &str =
"Could not confirm the save. Check the provider list before adding it again.";
pub(crate) enum ProviderWrite {
Saved(ModelProviderSummary),
UnconfirmedCreate,
}
pub(crate) async fn list_providers(
handle: &AppServerRequestHandle,
) -> Result<ModelProviderListResponse, String> {
@@ -23,6 +35,38 @@ pub(crate) async fn list_providers(
.map_err(request_error)
}
pub(crate) async fn write_provider(
handle: &AppServerRequestHandle,
params: ModelProviderConfigWriteParams,
) -> Result<ProviderWrite, String> {
let create = matches!(&params.settings, ProviderSettingsPatch::CustomGateway(gateway) if gateway.provider_id.is_none());
match handle
.request_typed::<ModelProviderConfigWriteResponse>(
ClientRequest::ModelProviderConfigWrite {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params,
},
)
.await
{
Ok(response) => Ok(ProviderWrite::Saved(response.provider)),
// A lost reply or a failed read-back can follow a committed write.
Err(err)
if create
&& match &err {
TypedRequestError::Server { source, .. } => source.code == -32603,
TypedRequestError::Transport { .. } | TypedRequestError::Deserialize { .. } => {
true
}
} =>
{
Ok(ProviderWrite::UnconfirmedCreate)
}
Err(TypedRequestError::Server { source, .. }) => Err(source.message),
Err(err) => Err(err.to_string()),
}
}
pub(crate) async fn set_default_provider(
handle: &AppServerRequestHandle,
id: &str,
+11 -29
View File
@@ -14,14 +14,9 @@ use super::progress;
use super::rpc;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ModelProviderConfigWriteParams;
use codex_app_server_protocol::ModelProviderConfigWriteResponse;
use codex_app_server_protocol::ProviderSettingsPatch;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::WriteStatus;
use tokio::sync::watch;
use uuid::Uuid;
const SAVING_VIEW: &str = "model_provider_saving";
@@ -94,29 +89,16 @@ impl Write {
provider_changed: &mut bool,
) -> Result<SaveNotice, String> {
match self {
Self::Settings(settings) => handle
.request_typed::<ModelProviderConfigWriteResponse>(
ClientRequest::ModelProviderConfigWrite {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: settings.1.clone(),
},
)
.await
.map(|_| SaveNotice::Saved("Saved provider configuration.".into()))
.or_else(|error| {
let create = matches!(&settings.1.settings,
ProviderSettingsPatch::CustomGateway(gateway) if gateway.provider_id.is_none());
// The server can report an internal error reading back a committed provider.
let uncertain = match &error {
TypedRequestError::Server { source, .. } => source.code == -32603,
TypedRequestError::Transport { .. } | TypedRequestError::Deserialize { .. } => true,
};
if create && uncertain {
Ok(SaveNotice::UnconfirmedCreate)
} else {
Err(error.to_string())
}
}),
Self::Settings(settings) => {
rpc::write_provider(handle, settings.1.clone())
.await
.map(|result| match result {
rpc::ProviderWrite::Saved(_) => {
SaveNotice::Saved("Saved provider configuration.".into())
}
rpc::ProviderWrite::UnconfirmedCreate => SaveNotice::UnconfirmedCreate,
})
}
Self::Default(id) => {
let response = rpc::set_default_provider(handle, id)
.await
@@ -168,7 +150,7 @@ impl Completion {
message
}
SaveNotice::UnconfirmedCreate => {
let message = "Could not confirm the save. Check the provider list before adding it again.".to_string();
let message = rpc::UNCONFIRMED_CREATE.to_string();
app.chat_widget.add_error_message(message.clone());
message
}
+1 -1
View File
@@ -452,7 +452,7 @@ async fn adding_a_custom_provider_saves_to_the_server_and_preserves_the_default(
);
let chat = &mut app.chat_widget;
insta::assert_snapshot!(render_bottom_popup(chat, /*width*/ 72), @r"
Add custom provider
Create a custom provider or gateway
› Name* Team gateway
Base URL* https://gateway.example/v1
@@ -6,8 +6,8 @@ expression: "render_bottom_popup(&chat, 80)"
Current chat: ollama · Default for new chats: lmstudio
› 1. ○ OpenAI openai
2. Add custom provider
› 1. ○ OpenAI openai
2. Create a custom provider or gateway
Use /new to start a chat with the selected provider.
enter details · space set default · esc back
@@ -6,9 +6,9 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
Current chat: test-provider · Default for new chats: gateway
› 1. ● Example Gateway gateway
2. ○ OpenAI openai
3. Add custom provider
› 1. ● Example Gateway gateway
2. ○ OpenAI openai
3. Create a custom provider or gateway
/new now uses gateway. Restart the app server and TUI to refresh /model.
enter details · esc back
+1
View File
@@ -16,6 +16,7 @@ use std::collections::BTreeMap;
pub(super) const FAILURE_HINT: &str = "To work without the background server, rerun the same command with --no-daemon (including resume or fork and its arguments).";
pub(super) const WSL_DRVFS_EXCLUSION: &str = "a Windows-mounted WSL CODEX_HOME (DrvFS/9p)";
pub(super) const GATEWAY_SIGN_IN_EXCLUSION: &str = "custom provider or gateway sign-in";
/// Returns whether `codex_home` is on a Windows-mounted WSL filesystem.
pub fn uses_wsl_drvfs(codex_home: &std::path::Path) -> bool {
+4
View File
@@ -414,6 +414,10 @@ fn daemon_exclusion_warning_snapshot() {
"Running without the shared background server: {} requires embedded mode.",
daemon_startup::WSL_DRVFS_EXCLUSION
),
format!(
"Running without the shared background server: {} requires embedded mode.",
daemon_startup::GATEWAY_SIGN_IN_EXCLUSION
),
]);
let text = cell
.transcript_lines(/*width*/ 80)
+18 -3
View File
@@ -54,6 +54,7 @@ use crate::motion::MotionMode;
use crate::motion::shimmer_text;
use crate::onboarding::bedrock::BedrockState;
use crate::onboarding::gateway::GatewayStep;
use crate::onboarding::gateway::USE_CUSTOM_PROVIDER;
use crate::onboarding::gcp::GcpState;
use crate::onboarding::keys;
use crate::onboarding::onboarding_screen::KeyboardHandler;
@@ -276,6 +277,13 @@ impl KeyboardHandler for AuthModeWidget {
}
fn handle_paste(&mut self, pasted: String) {
if let Some(wizard) = &mut self.gateway {
if let Some(input) = wizard.active_input_mut() {
input.paste(&pasted);
self.request_frame.schedule_frame();
}
return;
}
let mut state = self.sign_in_state.write().unwrap();
let input = match &mut *state {
SignInState::Bedrock(state) => state.active_input_mut(),
@@ -363,6 +371,9 @@ impl AuthModeWidget {
/// Returns whether the auth flow is currently accepting text input.
pub(crate) fn is_text_entry_active(&self) -> bool {
if let Some(wizard) = &self.gateway {
return wizard.is_text_entry_active();
}
self.sign_in_state.read().is_ok_and(|guard| match &*guard {
SignInState::ApiKeyEntry(_) => true,
SignInState::Bedrock(state) => state.is_text_entry_active(),
@@ -376,6 +387,9 @@ impl AuthModeWidget {
/// OpenAI API-key entry keeps its existing empty-field quit behavior, while
/// Cloud setup fields accept printable input from their first character.
pub(crate) fn should_suppress_printable_quit(&self) -> bool {
if let Some(wizard) = &self.gateway {
return wizard.is_text_entry_active();
}
self.sign_in_state.read().is_ok_and(|guard| match &*guard {
SignInState::ApiKeyEntry(state) => !state.value.is_empty(),
SignInState::Bedrock(state) => state.is_text_entry_active(),
@@ -653,8 +667,8 @@ impl AuthModeWidget {
lines.extend(create_mode_item(
idx,
option,
"Use a custom gateway",
"Choose a saved gateway",
USE_CUSTOM_PROVIDER,
"Choose a saved configuration or create a new one",
));
}
}
@@ -1271,7 +1285,7 @@ impl WidgetRef for AuthModeWidget {
.render(area, buf);
}
SignInState::GatewaySelected(_) | SignInState::GatewayConfigured => {
Paragraph::new("✓ Custom gateway configured".green())
Paragraph::new("✓ Custom provider configured".green())
.wrap(Wrap { trim: false })
.render(area, buf);
}
@@ -1421,6 +1435,7 @@ mod tests {
// Embedded startup can refresh cloud config using the host's workload identity.
let loader_overrides = LoaderOverrides {
ignore_managed_requirements: true,
managed_config_path: Some(codex_home_path.join("managed_config.toml")),
..LoaderOverrides::without_managed_config_for_tests()
};
let config = ConfigBuilder::default()
+127 -40
View File
@@ -1,13 +1,19 @@
//! Sign in by selecting a saved custom gateway as the default provider.
//! Gateway sign-in uses the settings picker and form. Creating a gateway returns to the
//! picker; only explicitly selecting a saved provider completes sign-in.
use super::auth::AuthModeWidget;
use super::auth::SignInState;
use super::keys;
use super::provider_setup::ProviderInput;
use crate::app::provider::CREATE_CUSTOM_PROVIDER;
use crate::app::provider::Draft;
use crate::app::provider::ProviderForm;
use crate::app::provider::provider_item;
use crate::app::provider::rpc;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::ListSelectionView;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::accept_cancel_hint_line;
use crate::key_hint::KeyBindingListExt;
@@ -15,6 +21,7 @@ use crate::keymap::ListAction;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ModelProviderConfigWriteParams;
use codex_app_server_protocol::ModelProviderListResponse;
use codex_app_server_protocol::ProviderSettings;
use codex_app_server_protocol::WriteStatus;
@@ -28,28 +35,29 @@ use ratatui::text::Line;
use ratatui::widgets::Widget;
use tokio::sync::mpsc::unbounded_channel;
pub(super) const USE_CUSTOM_PROVIDER: &str = "Use a custom provider or gateway";
// Requests stay on the onboarding thread so a redraw can stop polling without losing the reply.
pub(super) enum GatewayStep {
Loading(LocalBoxFuture<'static, Self>),
LoadError(Box<ListSelectionView>),
Picking(Box<Picker>),
Editing {
picker: Box<Picker>,
form: Box<ProviderForm>,
},
Saving(LocalBoxFuture<'static, Self>),
Selecting(LocalBoxFuture<'static, Result<String, Self>>),
}
impl GatewayStep {
fn load(handle: AppServerRequestHandle) -> Self {
fn load(handle: AppServerRequestHandle, notice: Option<String>) -> Self {
Self::Loading(Box::pin(async move {
match rpc::list_providers(&handle).await {
Ok(mut page) if page.can_change_default => {
page.data
.retain(|p| matches!(p.settings, ProviderSettings::CustomGateway { .. }));
if page.data.is_empty() {
Self::load_error("No custom gateways are configured.".into())
} else {
Self::Picking(Box::new(Picker::new(
page, /*selected*/ None, /*notice*/ None,
)))
}
Self::Picking(Box::new(Picker::new(page, /*selected*/ None, notice)))
}
Ok(_) => Self::load_error(
"The default provider is set by configuration policy or a launch override."
@@ -63,16 +71,57 @@ impl GatewayStep {
fn load_error(message: String) -> Self {
let (tx, _) = unbounded_channel();
Self::LoadError(Box::new(ListSelectionView::new(
crate::app::provider::load_error("Use a custom gateway", message),
crate::app::provider::load_error(USE_CUSTOM_PROVIDER, message),
AppEventSender::new(tx),
RuntimeKeymap::defaults().list,
)))
}
fn save(
handle: AppServerRequestHandle,
picker: Box<Picker>,
mut form: Box<ProviderForm>,
params: ModelProviderConfigWriteParams,
) -> Self {
GatewayStep::Saving(Box::pin(async move {
match rpc::write_provider(&handle, params).await {
Ok(rpc::ProviderWrite::Saved(provider)) => {
let mut page = picker.page;
let selected = page.data.len();
page.data.push(provider);
GatewayStep::Picking(Box::new(Picker::new(
page,
Some(selected),
/*notice*/ None,
)))
}
Ok(rpc::ProviderWrite::UnconfirmedCreate) => {
GatewayStep::load(handle, Some(rpc::UNCONFIRMED_CREATE.into()))
}
Err(message) => {
form.error = Some(message);
GatewayStep::Editing { picker, form }
}
}
}))
}
pub(super) fn active_input_mut(&mut self) -> Option<&mut ProviderInput> {
match self {
Self::Editing { form, .. } => form.active_input_mut(),
_ => None,
}
}
pub(super) fn is_text_entry_active(&self) -> bool {
matches!(self, Self::Editing { form, .. } if form.active_input().is_some())
}
pub(super) fn render(&self, area: Rect, buf: &mut Buffer) {
match self {
Self::Loading(_) => Widget::render(Line::from("Loading gateways…".dim()), area, buf),
Self::Selecting(_) => {
Self::Editing { form, .. } => form.render(area, buf),
Self::Saving(_) | Self::Selecting(_) => {
Widget::render(Line::from("Saving gateway settings…".dim()), area, buf)
}
Self::LoadError(list) => list.render(area, buf),
@@ -92,23 +141,31 @@ impl Picker {
selected: Option<usize>,
notice: Option<String>,
) -> Self {
let items = page
let mut items: Vec<_> = page
.data
.iter()
.map(|p| provider_item(p, &page.default_provider_id))
.collect();
let keymap = RuntimeKeymap::defaults().list;
let hint = |accept| {
accept_cancel_hint_line(
keymap.primary_hint(ListAction::Accept),
accept,
keymap.primary_hint(ListAction::Cancel),
"to go back",
)
};
items.push(SelectionItem {
name: CREATE_CUSTOM_PROVIDER.into(),
footer_hint: Some(hint("to create")),
..Default::default()
});
// Like the other startup pickers, consume the selection directly before the app event loop exists.
let (tx, _) = unbounded_channel();
let list = ListSelectionView::new(
SelectionViewParams {
title: Some("Use a custom gateway".into()),
footer_hint: Some(accept_cancel_hint_line(
keymap.primary_hint(ListAction::Accept),
"to use as default",
keymap.primary_hint(ListAction::Cancel),
"to go back",
)),
title: Some(USE_CUSTOM_PROVIDER.into()),
footer_hint: Some(hint("to use as default")),
footer_note: notice.map(Into::into),
initial_selected_idx: selected,
auto_number_shortcuts: false,
@@ -120,18 +177,50 @@ impl Picker {
);
Self { page, list }
}
fn activate(self: Box<Self>, index: usize, handle: AppServerRequestHandle) -> GatewayStep {
let Some(provider) = self.page.data.get(index) else {
let form = ProviderForm::new(
Draft::new(/*provider*/ None, &self.page.data),
/*error*/ None,
);
return GatewayStep::Editing {
picker: self,
form: Box::new(form),
};
};
let id = provider.id.clone();
GatewayStep::Selecting(Box::pin(async move {
let message = match rpc::set_default_provider(&handle, &id).await {
Ok(response) => match response.status {
WriteStatus::Ok => return Ok(id),
WriteStatus::OkOverridden => rpc::override_reason(response),
},
Err(err) => err.to_string(),
};
Err(GatewayStep::Picking(Box::new(Picker::new(
self.page,
Some(index),
Some(message),
))))
}))
}
}
impl AuthModeWidget {
pub(super) fn start_gateway_picker(&mut self) {
self.set_error(/*message*/ None);
self.gateway = Some(GatewayStep::load(self.app_server_request_handle.clone()));
self.gateway = Some(GatewayStep::load(
self.app_server_request_handle.clone(),
/*notice*/ None,
));
self.request_frame.schedule_frame();
}
pub(super) async fn advance_gateway_request(&mut self) {
self.gateway = match &mut self.gateway {
Some(GatewayStep::Loading(request)) => Some(request.await),
Some(GatewayStep::Loading(request) | GatewayStep::Saving(request)) => {
Some(request.await)
}
Some(GatewayStep::Selecting(request)) => match request.await {
Ok(id) => {
*self
@@ -143,9 +232,10 @@ impl AuthModeWidget {
}
Err(picker) => Some(picker),
},
None | Some(GatewayStep::Picking(_) | GatewayStep::LoadError(_)) => {
std::future::pending().await
}
None
| Some(
GatewayStep::Picking(_) | GatewayStep::LoadError(_) | GatewayStep::Editing { .. },
) => std::future::pending().await,
};
self.request_frame.schedule_frame();
}
@@ -160,6 +250,18 @@ impl AuthModeWidget {
{
None
}
GatewayStep::Editing { picker, .. } if keys::CANCEL.is_pressed(key) => {
Some(GatewayStep::Picking(picker))
}
GatewayStep::Editing { picker, mut form } => {
if let Some(params) = form.input(key) {
self.guard_activation(key);
let handle = self.app_server_request_handle.clone();
Some(GatewayStep::save(handle, picker, form, params))
} else {
Some(GatewayStep::Editing { picker, form })
}
}
GatewayStep::LoadError(mut view) => {
view.handle_key_event(key);
if view.take_last_selected_index().is_some() {
@@ -175,22 +277,7 @@ impl AuthModeWidget {
picker.list.handle_key_event(key);
if let Some(index) = picker.list.take_last_selected_index() {
self.guard_activation(key);
let id = picker.page.data[index].id.clone();
let handle = self.app_server_request_handle.clone();
Some(GatewayStep::Selecting(Box::pin(async move {
let message = match rpc::set_default_provider(&handle, &id).await {
Ok(response) => match response.status {
WriteStatus::Ok => return Ok(id),
WriteStatus::OkOverridden => rpc::override_reason(response),
},
Err(err) => err.to_string(),
};
Err(GatewayStep::Picking(Box::new(Picker::new(
picker.page,
Some(index),
Some(message),
))))
})))
Some(picker.activate(index, self.app_server_request_handle.clone()))
} else {
Some(GatewayStep::Picking(picker))
}
+122 -5
View File
@@ -1,8 +1,14 @@
#![cfg(test)]
use super::*;
use crate::app::provider::rpc::list_providers;
use crate::app::provider::rpc::write_provider;
use codex_app_server_protocol::CustomGatewaySettingsParams;
use codex_app_server_protocol::ProviderSettingsPatch;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use serde_json::json;
fn screen(widget: &AuthModeWidget, height: u16) -> String {
let area = Rect::new(0, 0, 72, height);
@@ -35,13 +41,18 @@ async fn select_a_saved_gateway_during_sign_in() -> color_eyre::Result<()> {
insta::assert_snapshot!("gateway_sign_in_menu", screen(&widget, /*height*/ 8));
widget.handle_key_event(KeyCode::Char('4').into());
widget.advance_gateway_request().await;
insta::assert_snapshot!("gateway_load_error", screen(&widget, /*height*/ 8));
insta::assert_snapshot!("gateway_empty_picker", screen(&widget, /*height*/ 8));
widget.handle_key_event(KeyCode::Esc.into());
widget.handle_key_event(KeyEvent {
kind: KeyEventKind::Release,
..KeyCode::Char('4').into()
});
let managed = home.path().join("managed_config.toml");
std::fs::write(&managed, "model_provider = 'openai'")?;
widget.handle_key_event(KeyCode::Char('4').into());
widget.advance_gateway_request().await;
let error = screen(&widget, /*height*/ 8);
assert!(error.contains("configuration policy"));
insta::assert_snapshot!("gateway_load_error", error);
widget.handle_key_event(KeyCode::Esc.into());
std::fs::remove_file(managed)?;
std::fs::write(
home.path().join("config.toml"),
r#"
@@ -57,12 +68,118 @@ wire_api = "responses"
widget.handle_key_event(KeyCode::Enter.into());
widget.advance_gateway_request().await;
assert!(matches!(widget.get_step_state(), StepState::Complete));
insta::assert_snapshot!(screen(&widget, /*height*/ 4), @"✓ Custom provider configured");
let saved: toml::Value =
toml::from_str(&std::fs::read_to_string(home.path().join("config.toml"))?)?;
assert_eq!(saved["model_provider"].as_str(), Some("office"));
Ok(())
}
#[tokio::test]
async fn create_highlights_the_saved_gateway_until_explicitly_selected() -> color_eyre::Result<()> {
let (mut widget, home) = super::tests::widget_forced_chatgpt().await;
widget.auth_config.forced_login_method = None;
widget.bedrock_setup_enabled = true;
widget.gcp_setup_enabled = true;
widget.gateway_setup_enabled = true;
write_provider(
&widget.app_server_request_handle,
codex_app_server_protocol::ModelProviderConfigWriteParams {
settings: ProviderSettingsPatch::CustomGateway(CustomGatewaySettingsParams {
name: Some("Office".into()),
base_url: Some(Some("https://office.example.test/v1".into())),
..Default::default()
}),
},
)
.await
.map_err(color_eyre::Report::msg)?;
widget.handle_key_event(KeyCode::Char('3').into());
widget.handle_key_event(KeyCode::Char('4').into());
widget.advance_gateway_request().await;
widget.handle_key_event(KeyCode::Down.into());
widget.handle_key_event(KeyCode::Enter.into());
insta::assert_snapshot!(screen(&widget, /*height*/ 12), @r"
Create a custom provider or gateway
› Name* Team gateway
Base URL* https://gateway.example/v1
API key variable $TEAM_API_KEY
Save
* Required
Enter the environment variable name on the app server.
tab move · enter next/save · esc back
");
widget.handle_paste("Team gateway".into());
widget.handle_key_event(KeyCode::Down.into());
widget.handle_paste("https://team.example.test/v1?".into());
widget.handle_key_event(KeyCode::Down.into());
widget.handle_paste("TEAM_KEY-".into());
widget.handle_key_event(KeyCode::Down.into());
insta::assert_snapshot!("gateway_save_focused_short", screen(&widget, /*height*/ 4));
widget.handle_key_event(KeyCode::Enter.into());
insta::assert_snapshot!("gateway_save_error_short", screen(&widget, /*height*/ 4));
widget.handle_key_event(KeyCode::Up.into());
widget.handle_key_event(KeyCode::Backspace.into());
widget.handle_key_event(KeyCode::Down.into());
widget.handle_key_event(KeyCode::Enter.into());
widget.advance_gateway_request().await;
insta::assert_snapshot!("gateway_validation_retry", screen(&widget, /*height*/ 8));
// Correct the invalid URL and keep the other fields.
widget.handle_key_event(KeyCode::Up.into());
widget.handle_key_event(KeyCode::Up.into());
widget.handle_key_event(KeyCode::Backspace.into());
widget.handle_key_event(KeyCode::Down.into());
widget.handle_key_event(KeyCode::Down.into());
widget.handle_key_event(KeyCode::Enter.into());
widget.advance_gateway_request().await;
assert_eq!(widget.error_message(), None);
widget.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::NONE,
KeyEventKind::Repeat,
));
assert!(matches!(widget.get_step_state(), StepState::InProgress));
insta::assert_snapshot!("gateway_created_picker", screen(&widget, /*height*/ 8));
let page = list_providers(&widget.app_server_request_handle)
.await
.map_err(color_eyre::Report::msg)?;
assert_eq!(page.default_provider_id, "openai");
widget.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::NONE,
KeyEventKind::Release,
));
widget.handle_key_event(KeyCode::Enter.into());
widget.advance_gateway_request().await;
assert!(matches!(widget.get_step_state(), StepState::Complete));
let saved: serde_json::Value =
toml::from_str(&std::fs::read_to_string(home.path().join("config.toml"))?)?;
assert_eq!(
saved,
json!({
"model_provider": "team-gateway",
"model_providers": {
"office": {
"name": "Office",
"base_url": "https://office.example.test/v1",
"wire_api": "responses",
},
"team-gateway": {
"name": "Team gateway",
"base_url": "https://team.example.test/v1",
"env_key": "TEAM_KEY",
"wire_api": "responses",
},
},
})
);
Ok(())
}
#[tokio::test]
async fn gateway_oauth_displays_the_browser_link_and_retry_action() {
let (widget, _home) = super::tests::widget_forced_chatgpt().await;
@@ -0,0 +1,12 @@
---
source: tui/src/onboarding/gateway_tests.rs
expression: screen(&widget)
---
Use a custom provider or gateway
○ Office office
› ○ Team gateway team-gateway
Create a custom provider or gateway
Press enter to use as default or esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/onboarding/gateway_tests.rs
expression: screen(&widget)
---
Use a custom provider or gateway
› Create a custom provider or gateway
Press enter to create or esc to go back
@@ -1,9 +1,10 @@
---
source: tui/src/onboarding/gateway_tests.rs
expression: "screen(&widget, 8)"
expression: error
---
Use a custom gateway
No custom gateways are configured.
Use a custom provider or gateway
The default provider is set by configuration policy or a launch
override.
› 1. Back
@@ -0,0 +1,8 @@
---
source: tui/src/onboarding/gateway_tests.rs
expression: "screen(&widget, 4)"
---
› Save
Enter an environment variable name such as TEAM_API_KEY.
* Required
@@ -0,0 +1,8 @@
---
source: tui/src/onboarding/gateway_tests.rs
expression: "screen(&widget, 4)"
---
› Save
* Required
Enter the environment variable name on the app server.
@@ -2,11 +2,11 @@
source: tui/src/onboarding/gateway_tests.rs
expression: screen(&widget)
---
Use a custom gateway
Use a custom provider or gateway
› ○ Office office
› ○ Office office
Create a custom provider or gateway
Press enter to use as default or esc to go back
@@ -7,6 +7,6 @@ expression: terminal.backend().to_string()
> 1. Use an OpenAI API key
2. Use Amazon Bedrock
3. Use Google Cloud Vertex AI
4. Use a custom gateway
4. Use a custom provider or gateway
Press enter to continue
Press esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/onboarding/gateway_tests.rs
expression: "screen(&widget, 8)"
---
Base URL* https://team.example.test/v1?
API key variable $TEAM_KEY
› Save
Enter an HTTP(S) base URL without credentials, a query, or a fragment.
* Required
Enter the environment variable name on the app server.
tab move · enter next/save · esc back
@@ -6,3 +6,5 @@ expression: text
embedded mode.
⚠ Running without the shared background server: a Windows-mounted WSL CODEX_HOME
(DrvFS/9p) requires embedded mode.
⚠ Running without the shared background server: custom provider or gateway sign-
in requires embedded mode.
+2 -2
View File
@@ -602,7 +602,7 @@ pub(super) async fn run_main_inner(
daemon_exclusion = Some(if config.features.enabled(Feature::GcpSetupWizard) {
"GCP sign-in"
} else {
"custom gateway sign-in"
daemon_startup::GATEWAY_SIGN_IN_EXCLUSION
});
app_server_target = AppServerTarget::Embedded;
}
@@ -822,7 +822,7 @@ pub(super) async fn run_main_inner(
}
}
Some("GCP sign-in") => "gcp_sign_in",
Some("custom gateway sign-in") => "gateway_sign_in",
Some(daemon_startup::GATEWAY_SIGN_IN_EXCLUSION) => "gateway_sign_in",
Some(daemon_startup::WSL_DRVFS_EXCLUSION) => "wsl_drvfs",
Some("this Windows launcher") => "windows_detach_restricted",
Some("daemon feature settings") => "daemon_compatibility",
@@ -189,7 +189,7 @@ requires_openai_auth = true
.with_context(|| terminal.screen_contents())?;
// Startup drains input after its first draw. Retry the menu shortcut until it is accepted.
let deadline = std::time::Instant::now() + Duration::from_secs(/*secs*/ 30);
while !terminal.screen_contains("Use a custom gateway") {
while !terminal.screen_contains("Use a custom provider or gateway") {
ensure!(
std::time::Instant::now() < deadline,
"gateway option missing: {}",