Complete local-first Rig provider migration
This commit is contained in:
@@ -150,12 +150,14 @@ impl AIAgentActionResultType {
|
||||
..
|
||||
} => command_result_content(Some(command), output, exit_code.value()),
|
||||
RequestCommandOutputResult::LongRunningCommandSnapshot {
|
||||
block_id,
|
||||
command,
|
||||
grid_contents,
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
..
|
||||
} => shell_snapshot_content(
|
||||
block_id,
|
||||
Some(command),
|
||||
grid_contents,
|
||||
cursor,
|
||||
@@ -167,12 +169,14 @@ impl AIAgentActionResultType {
|
||||
},
|
||||
Self::WriteToLongRunningShellCommand(result) => match result {
|
||||
WriteToLongRunningShellCommandResult::Snapshot {
|
||||
block_id,
|
||||
grid_contents,
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
..
|
||||
} => shell_snapshot_content(
|
||||
block_id,
|
||||
None,
|
||||
grid_contents,
|
||||
cursor,
|
||||
@@ -229,6 +233,7 @@ impl AIAgentActionResultType {
|
||||
..
|
||||
} => command_result_content(Some(command), output, exit_code.value()),
|
||||
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
|
||||
block_id,
|
||||
command,
|
||||
grid_contents,
|
||||
cursor,
|
||||
@@ -236,6 +241,7 @@ impl AIAgentActionResultType {
|
||||
is_preempted,
|
||||
..
|
||||
} => shell_snapshot_content(
|
||||
block_id,
|
||||
Some(command),
|
||||
grid_contents,
|
||||
cursor,
|
||||
@@ -247,6 +253,7 @@ impl AIAgentActionResultType {
|
||||
},
|
||||
Self::TransferShellCommandControlToUser(result) => match result {
|
||||
TransferShellCommandControlToUserResult::Snapshot {
|
||||
block_id,
|
||||
grid_contents,
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
@@ -255,6 +262,7 @@ impl AIAgentActionResultType {
|
||||
} => format!(
|
||||
"{}\nControl has been transferred to the user. Do not write to the command until control is returned.",
|
||||
shell_snapshot_content(
|
||||
block_id,
|
||||
None,
|
||||
grid_contents,
|
||||
cursor,
|
||||
@@ -305,6 +313,7 @@ fn command_result_content(command: Option<&str>, output: &str, exit_code: i32) -
|
||||
}
|
||||
|
||||
fn shell_snapshot_content(
|
||||
block_id: &BlockId,
|
||||
command: Option<&str>,
|
||||
grid_contents: &str,
|
||||
cursor: &str,
|
||||
@@ -318,7 +327,7 @@ fn shell_snapshot_content(
|
||||
.map(|is_preempted| format!("\nPreempted: {is_preempted}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{command}Command is still running.\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}"
|
||||
"{command}Command is still running.\nCommand ID: {block_id}\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}\nContinue monitoring with `read_shell_command_output` using command_id `{block_id}` and a short wait. Do not report completion while the command is still running."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use galaxy_agent_core::{
|
||||
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
|
||||
MessageContent, MessageRole, RuntimeCapabilities, RuntimeDescriptor, RuntimeKind, TurnControl,
|
||||
TurnRequest, turn_control,
|
||||
};
|
||||
use rig_core::client::CompletionClient;
|
||||
use rig_core::providers::chatgpt;
|
||||
|
||||
use crate::request::build_completion_request;
|
||||
use crate::stream::start_model_turn;
|
||||
|
||||
/// The information a user needs to complete ChatGPT's device authorization flow.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ChatGPTDeviceCode {
|
||||
pub verification_uri: String,
|
||||
pub user_code: String,
|
||||
}
|
||||
|
||||
/// Small application-facing wrapper around Rig's native ChatGPT OAuth client.
|
||||
///
|
||||
/// Keeping the Rig auth type behind this wrapper lets Galaxy present device-code
|
||||
/// instructions without depending on Rig's private auth module.
|
||||
pub struct ChatGPTSubscriptionClient {
|
||||
client: chatgpt::Client,
|
||||
}
|
||||
|
||||
impl ChatGPTSubscriptionClient {
|
||||
pub fn with_device_code_handler<F>(handler: F) -> Result<Self, String>
|
||||
where
|
||||
F: Fn(ChatGPTDeviceCode) + Send + Sync + 'static,
|
||||
{
|
||||
let client = chatgpt::Client::builder()
|
||||
.oauth()
|
||||
.on_device_code(move |prompt| {
|
||||
handler(ChatGPTDeviceCode {
|
||||
verification_uri: prompt.verification_uri,
|
||||
user_code: prompt.user_code,
|
||||
});
|
||||
})
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
pub async fn authorize(&self) -> Result<(), String> {
|
||||
self.client
|
||||
.authorize()
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ChatGPTSubscriptionRuntimeConfig {
|
||||
pub model: String,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub auth_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChatGPTSubscriptionRuntime {
|
||||
config: ChatGPTSubscriptionRuntimeConfig,
|
||||
descriptor: RuntimeDescriptor,
|
||||
}
|
||||
|
||||
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
|
||||
reasoning_effort.map(|effort| {
|
||||
// Codex exposes `ultra` as a client-side delegation mode, while the
|
||||
// Responses wire format carries its maximum reasoning as `max`.
|
||||
let wire_effort = if effort == "ultra" { "max" } else { effort };
|
||||
serde_json::json!({"reasoning": {"effort": wire_effort}})
|
||||
})
|
||||
}
|
||||
|
||||
impl ChatGPTSubscriptionRuntime {
|
||||
pub fn new(config: ChatGPTSubscriptionRuntimeConfig) -> Self {
|
||||
let descriptor = RuntimeDescriptor {
|
||||
id: format!("rig-chatgpt-subscription:{}", config.model),
|
||||
display_name: format!("ChatGPT Subscription / {}", config.model),
|
||||
kind: RuntimeKind::Provider,
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
};
|
||||
Self { config, descriptor }
|
||||
}
|
||||
|
||||
/// Completes a text-only request for callers such as crosscheck reviewers.
|
||||
pub async fn complete_text(
|
||||
&self,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
) -> Result<String, String> {
|
||||
let mut request = TurnRequest::new(
|
||||
self.config.model.clone(),
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(user_prompt),
|
||||
}],
|
||||
);
|
||||
request.system_prompt = Some(system_prompt);
|
||||
request.max_output_tokens = self.config.max_output_tokens;
|
||||
|
||||
let (_sender, control) = turn_control();
|
||||
let mut stream = self
|
||||
.start_turn(request, control)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut output = String::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
match event.map_err(|error| error.to_string())? {
|
||||
AgentEvent::TextDelta { text } => output.push_str(&text),
|
||||
AgentEvent::ReasoningDelta { .. }
|
||||
| AgentEvent::ReasoningCompleted { .. }
|
||||
| AgentEvent::TurnStarted { .. }
|
||||
| AgentEvent::UsageUpdated { .. }
|
||||
| AgentEvent::RuntimeActivityUpdated { .. }
|
||||
| AgentEvent::ContextUsageUpdated { .. }
|
||||
| AgentEvent::UserInputAccepted { .. }
|
||||
| AgentEvent::RuntimeNotice { .. }
|
||||
| AgentEvent::TurnStopped { .. }
|
||||
| AgentEvent::Tool { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
return Err("ChatGPT returned an empty response.".to_string());
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for ChatGPTSubscriptionRuntime {
|
||||
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||
&self.descriptor
|
||||
}
|
||||
|
||||
async fn start_turn(
|
||||
&self,
|
||||
request: TurnRequest,
|
||||
control: TurnControl,
|
||||
) -> Result<AgentEventStream, AgentError> {
|
||||
let mut builder = chatgpt::Client::builder().oauth();
|
||||
if let Some(auth_file) = &self.config.auth_file {
|
||||
builder = builder.auth_file(auth_file);
|
||||
}
|
||||
let client = builder
|
||||
.build()
|
||||
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
|
||||
let model = client.completion_model(&self.config.model);
|
||||
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
|
||||
let additional_params =
|
||||
reasoning_additional_params(self.config.reasoning_effort.as_deref());
|
||||
let completion_request = build_completion_request(
|
||||
request,
|
||||
self.config.max_output_tokens,
|
||||
true,
|
||||
false,
|
||||
additional_params,
|
||||
)?;
|
||||
|
||||
start_model_turn(model, completion_request, control, max_output_tokens).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_descriptor_identifies_chatgpt_subscription() {
|
||||
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
max_output_tokens: None,
|
||||
auth_file: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
runtime.descriptor().id,
|
||||
"rig-chatgpt-subscription:gpt-5.3-codex"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_is_encoded_for_the_responses_request() {
|
||||
let request = TurnRequest::new(
|
||||
"gpt-5.4".to_string(),
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("hello".to_string()),
|
||||
}],
|
||||
);
|
||||
let request = build_completion_request(
|
||||
request,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
reasoning_additional_params(Some("xhigh")),
|
||||
)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(
|
||||
request.additional_params,
|
||||
reasoning_additional_params(Some("xhigh"))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
reasoning_additional_params(Some("ultra")),
|
||||
Some(serde_json::json!({
|
||||
"reasoning": { "effort": "max" }
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Rig-backed implementations of Galaxy's provider-neutral agent runtime.
|
||||
|
||||
mod bedrock;
|
||||
mod chatgpt;
|
||||
mod openai_compatible;
|
||||
mod request;
|
||||
mod stream;
|
||||
|
||||
pub use bedrock::*;
|
||||
pub use chatgpt::*;
|
||||
pub use openai_compatible::*;
|
||||
|
||||
@@ -276,6 +276,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn()
|
||||
panic!("expected assistant tool call content");
|
||||
};
|
||||
assert_eq!(call.id, "call-1");
|
||||
assert_eq!(call.call_id.as_deref(), Some("call-1"));
|
||||
assert_eq!(call.function.name, "run_shell_command");
|
||||
|
||||
let Message::User { content } = messages[1] else {
|
||||
@@ -285,6 +286,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn()
|
||||
panic!("expected user tool result content");
|
||||
};
|
||||
assert_eq!(result.id, "call-1");
|
||||
assert_eq!(result.call_id.as_deref(), Some("call-1"));
|
||||
let Some(ToolResultContent::Text(text)) = result.content.iter().next() else {
|
||||
panic!("expected text tool result");
|
||||
};
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) fn build_completion_request(
|
||||
tool_choice: None,
|
||||
additional_params,
|
||||
output_schema: None,
|
||||
record_telemetry_content: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,7 +88,8 @@ fn user_content(
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
} => vec![UserContent::tool_result(
|
||||
} => vec![UserContent::tool_result_with_call_id(
|
||||
tool_use_id.clone(),
|
||||
tool_use_id,
|
||||
OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))),
|
||||
)],
|
||||
@@ -112,7 +114,12 @@ fn assistant_content(
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => vec![AssistantContent::tool_call(tool_use_id, name, input)],
|
||||
} => vec![AssistantContent::tool_call_with_call_id(
|
||||
tool_use_id.clone(),
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
)],
|
||||
MessageContent::MultiPart(parts) => parts
|
||||
.into_iter()
|
||||
.map(|part| convert_assistant_part(part, encode_images_as_base64))
|
||||
@@ -147,7 +154,8 @@ fn convert_user_part(
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
} => Ok(UserContent::tool_result(
|
||||
} => Ok(UserContent::tool_result_with_call_id(
|
||||
tool_use_id.clone(),
|
||||
tool_use_id,
|
||||
OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))),
|
||||
)),
|
||||
@@ -178,7 +186,12 @@ fn convert_assistant_part(
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => Ok(AssistantContent::tool_call(tool_use_id, name, input)),
|
||||
} => Ok(AssistantContent::tool_call_with_call_id(
|
||||
tool_use_id.clone(),
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
)),
|
||||
ContentPart::ToolResult { .. } => Err(invalid_role("tool result", "assistant")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +103,7 @@ where
|
||||
Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => {
|
||||
yield Ok(AgentEvent::Tool {
|
||||
event: galaxy_agent_core::ToolEvent::Proposed {
|
||||
call: ToolCall {
|
||||
id: tool_call.id,
|
||||
name: tool_call.function.name,
|
||||
arguments: tool_call.function.arguments,
|
||||
},
|
||||
call: domain_tool_call(tool_call),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -146,6 +142,18 @@ where
|
||||
Ok(Box::pin(events))
|
||||
}
|
||||
|
||||
fn domain_tool_call(tool_call: rig_core::message::ToolCall) -> ToolCall {
|
||||
ToolCall {
|
||||
// OpenAI Responses uses a separate `call_id` for function-call output
|
||||
// correlation. The domain model has one ID, so preserve that value when
|
||||
// it is available and fall back to the standard tool-call ID for other
|
||||
// OpenAI-compatible providers.
|
||||
id: tool_call.call_id.unwrap_or(tool_call.id),
|
||||
name: tool_call.function.name,
|
||||
arguments: tool_call.function.arguments,
|
||||
}
|
||||
}
|
||||
|
||||
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
|
||||
Box::pin(futures::stream::iter([
|
||||
Ok(AgentEvent::TurnStarted { runtime_request_id }),
|
||||
@@ -206,3 +214,25 @@ fn map_completion_error(error: CompletionError) -> AgentError {
|
||||
);
|
||||
mapped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::domain_tool_call;
|
||||
|
||||
#[test]
|
||||
fn domain_tool_call_prefers_responses_call_id() {
|
||||
let tool_call = rig_core::message::ToolCall::new(
|
||||
"fc_item_123".to_string(),
|
||||
rig_core::message::ToolFunction {
|
||||
name: "read_files".to_string(),
|
||||
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
|
||||
},
|
||||
)
|
||||
.with_call_id("call_123".to_string());
|
||||
|
||||
let call = domain_tool_call(tool_call);
|
||||
|
||||
assert_eq!(call.id, "call_123");
|
||||
assert_eq!(call.name, "read_files");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,13 @@ fn only_oss_disables_warp_service_egress() {
|
||||
assert!(Channel::Integration.allows_warp_service_egress());
|
||||
assert!(!Channel::Oss.allows_warp_service_egress());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_oss_is_local_first() {
|
||||
assert!(!Channel::Stable.is_local_first());
|
||||
assert!(!Channel::Preview.is_local_first());
|
||||
assert!(!Channel::Dev.is_local_first());
|
||||
assert!(!Channel::Local.is_local_first());
|
||||
assert!(!Channel::Integration.is_local_first());
|
||||
assert!(Channel::Oss.is_local_first());
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ impl Channel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the channel is local-first and must not expose Warp-owned
|
||||
/// identity, sharing, or workspace state to the product surface.
|
||||
pub fn is_local_first(&self) -> bool {
|
||||
!self.allows_warp_service_egress()
|
||||
}
|
||||
|
||||
/// Returns the CLI command name corresponding to this channel.
|
||||
pub fn cli_command_name(&self) -> &'static str {
|
||||
match self {
|
||||
|
||||
Reference in New Issue
Block a user