mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
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:
@@ -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}))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
");
|
||||
}
|
||||
|
||||
@@ -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!(¶ms.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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+12
@@ -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
|
||||
+12
@@ -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
|
||||
+4
-3
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
+8
@@ -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.
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+12
@@ -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
|
||||
+2
@@ -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.
|
||||
|
||||
@@ -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: {}",
|
||||
|
||||
Reference in New Issue
Block a user