Merge remote-tracking branch 'ryserve/master'

# Conflicts:
#	app/src/ai/llms.rs
This commit is contained in:
2026-10-25 01:32:18 -05:00
126 changed files with 7937 additions and 1147 deletions
+52 -22
View File
@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use futures::future::BoxFuture;
use futures::StreamExt;
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
@@ -8,35 +9,50 @@ use galaxy_agent_core::{
TurnRequest, turn_control,
};
use rig_core::client::CompletionClient;
use rig_core::providers::chatgpt;
use rig_core::providers::chatgpt::{self, ChatGPTAuth};
use crate::request::build_completion_request;
use crate::stream::start_model_turn;
/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing
/// Rig to fall back to its interactive device-code flow.
pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> {
let client = chatgpt::Client::builder()
.oauth()
.allow_device_flow(false)
.originator("galaxy")
.build()
.map_err(|error| error.to_string())?;
client.authorize().await.map_err(|error| error.to_string())
#[derive(Clone, PartialEq, Eq)]
pub struct ChatGPTSubscriptionCredentials {
pub access_token: String,
pub account_id: Option<String>,
}
impl std::fmt::Debug for ChatGPTSubscriptionCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTSubscriptionCredentials")
.field("access_token", &"<redacted>")
.field("account_id", &self.account_id.as_ref().map(|_| "<redacted>"))
.finish()
}
}
pub type ChatGPTCredentialResolver = Arc<
dyn Fn() -> BoxFuture<'static, Result<ChatGPTSubscriptionCredentials, String>> + Send + Sync,
>;
#[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)]
#[derive(Clone)]
pub struct ChatGPTSubscriptionRuntime {
config: ChatGPTSubscriptionRuntimeConfig,
descriptor: RuntimeDescriptor,
credential_resolver: Option<ChatGPTCredentialResolver>,
}
impl std::fmt::Debug for ChatGPTSubscriptionRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTSubscriptionRuntime")
.field("config", &self.config)
.field("has_credential_resolver", &self.credential_resolver.is_some())
.finish()
}
}
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
@@ -56,7 +72,24 @@ impl ChatGPTSubscriptionRuntime {
kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(),
};
Self { config, descriptor }
Self { config, descriptor, credential_resolver: None }
}
pub fn with_credential_resolver(mut self, resolver: ChatGPTCredentialResolver) -> Self {
self.credential_resolver = Some(resolver);
self
}
async fn resolve_auth(&self) -> Result<ChatGPTAuth, AgentError> {
let resolver = self.credential_resolver.as_ref().ok_or_else(|| {
AgentError::new(AgentErrorKind::Authentication, "ChatGPT sign-in required. Reconnect ChatGPT in Providers.")
})?;
let credentials = resolver().await
.map_err(|message| AgentError::new(AgentErrorKind::Authentication, message))?;
Ok(ChatGPTAuth::AccessToken {
access_token: credentials.access_token,
account_id: credentials.account_id,
})
}
/// Completes a text-only request for callers such as crosscheck reviewers.
@@ -117,14 +150,11 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let mut builder = chatgpt::Client::builder()
.oauth()
let auth = self.resolve_auth().await?;
let client = chatgpt::Client::builder()
.api_key(auth)
.allow_device_flow(false)
.originator("galaxy");
if let Some(auth_file) = &self.config.auth_file {
builder = builder.auth_file(auth_file);
}
let client = builder
.originator("galaxy")
.build()
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model);
+108 -3
View File
@@ -1,13 +1,14 @@
use galaxy_agent_core::StopReason;
use rig_core::client::CompletionClient;
use rig_core::completion::{AssistantContent, CompletionModel, Message};
use rig_core::message::{ToolResultContent, UserContent};
use rig_core::providers::chatgpt::ChatGPTAuth;
use rig_core::test_utils::RecordingHttpClient;
use rig_core::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
use super::*;
const COMPLETED_RESPONSE: &str = r#"data: {"type":"response.output_text.delta","delta":"ok"}
data: {"type":"response.completed","response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
data: {"type":"response.completed","sequence_number":54,"response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
data: [DONE]"#;
#[test]
@@ -16,7 +17,6 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
model: "gpt-5.3-codex".to_string(),
reasoning_effort: Some("high".to_string()),
max_output_tokens: None,
auth_file: None,
});
assert_eq!(
@@ -25,6 +25,43 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
);
}
#[tokio::test]
async fn runtime_resolves_current_credentials_without_rig_oauth() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "test-model".to_owned(),
reasoning_effort: None,
max_output_tokens: None,
}).with_credential_resolver(std::sync::Arc::new(|| {
Box::pin(async {
Ok(ChatGPTSubscriptionCredentials {
access_token: "resolved-token".to_owned(),
account_id: Some("resolved-account".to_owned()),
})
})
}));
match runtime.resolve_auth().await.unwrap() {
ChatGPTAuth::AccessToken { access_token, account_id } => {
assert_eq!(access_token, "resolved-token");
assert_eq!(account_id.as_deref(), Some("resolved-account"));
}
ChatGPTAuth::OAuth => panic!("The runtime must not own OAuth refresh"),
}
}
#[tokio::test]
async fn missing_or_failed_resolver_requires_sign_in_without_device_flow() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "test-model".to_owned(),
reasoning_effort: None,
max_output_tokens: None,
});
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
let runtime = runtime.with_credential_resolver(std::sync::Arc::new(|| {
Box::pin(async { Err("Sign in again".to_owned()) })
}));
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
}
#[test]
fn reasoning_effort_is_encoded_for_the_responses_request() {
let request = TurnRequest::new(
@@ -115,3 +152,71 @@ async fn chatgpt_follow_up_request_preserves_responses_call_ids() {
assert_eq!(input[0]["call_id"], "call_native_1");
assert_eq!(input[1]["call_id"], "call_native_1");
}
#[tokio::test]
async fn chatgpt_metadata_does_not_interrupt_text_or_completion() {
let metadata = r#"data: {"type":"response.metadata","response_id":"resp_test","sequence_number":51,"metadata":{"openai_chatgpt_moderation_metadata":{"prompt":{},"generation":{},"tool_call":{},"tool_response":{}}}}"#;
let delta = serde_json::json!({
"type": "response.output_text.delta",
"item_id": "msg_test",
"output_index": 0,
"content_index": 0,
"sequence_number": 52,
"delta": "ok",
});
let completed = COMPLETED_RESPONSE
.lines()
.find(|line| line.starts_with("data: {\"type\":\"response.completed\""))
.expect("fixture should contain a completed response");
let response =
format!("{metadata}\n\ndata: {delta}\n\n{metadata}\n\n{completed}\n\ndata: [DONE]\n\n");
let client = chatgpt::Client::builder()
.api_key(ChatGPTAuth::AccessToken {
access_token: "test-token".to_string(),
account_id: None,
})
.http_client(SequencedStreamingHttpClient::new(vec![Ok(response.into())]))
.build()
.expect("client should build");
let request = build_completion_request(
TurnRequest::new(
"gpt-5.3-codex".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("hello".to_string()),
}],
),
None,
true,
true,
None,
)
.expect("request should convert");
let (_sender, control) = turn_control();
let mut stream = start_model_turn(
client.completion_model("gpt-5.3-codex"),
request,
control,
None,
)
.await
.expect("request should reach the mocked provider");
let mut text = String::new();
let mut usage = None;
let mut stop_reason = None;
while let Some(event) = stream.next().await {
match event.expect("metadata should not fail the turn") {
AgentEvent::TextDelta { text: delta } => text.push_str(&delta),
AgentEvent::UsageUpdated { usage: updated } => usage = Some(updated),
AgentEvent::TurnStopped { reason } => stop_reason = Some(reason),
_ => {}
}
}
assert_eq!(text, "ok");
assert_eq!(
usage.expect("completion should report usage").output_tokens,
1
);
assert_eq!(stop_reason, Some(StopReason::Completed));
}
+12
View File
@@ -160,6 +160,13 @@ where
});
}
Ok(StreamedAssistantContent::Unknown(value)) => {
// ChatGPT subscription streams include informational moderation
// metadata that Rig does not map to assistant content.
if value.get("type").and_then(serde_json::Value::as_str)
== Some("response.metadata")
{
continue;
}
if is_keepalive_event(&value) {
yield Ok(AgentEvent::KeepAlive);
} else {
@@ -512,6 +519,11 @@ fn text_indicates_authentication_failure(text: &str) -> bool {
"unrecognized client",
"security token included in the request is invalid",
"unable to locate credentials",
"unauthorizedexception",
"session token not found or invalid",
"sso session has expired",
"failed to load aws credentials",
"bedrock credentials not configured",
"credential should be scoped to a valid region",
]
.iter()
@@ -105,6 +105,10 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
r#"{"__type":"ExpiredTokenException","message":"The security token included in the request is expired"}"#,
"UnrecognizedClientException: The security token included in the request is invalid",
"AccessDeniedException: not authorized to perform bedrock:ConverseStream",
"UnauthorizedException: Session token not found or invalid",
"SSO session has expired or is otherwise invalid",
"Failed to load AWS credentials: credentials were not loaded",
"Bedrock credentials not configured",
] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_string()));
assert_eq!(mapped.kind, AgentErrorKind::Authentication);
@@ -112,3 +116,15 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
assert!(mapped.user_message.is_some());
}
}
#[test]
fn unrelated_provider_failures_do_not_become_authentication_failures() {
for message in [
"HTTP client error: connection reset",
"ProviderError: temporarily unavailable",
"ValidationException: invalid model",
] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_owned()));
assert_ne!(mapped.kind, AgentErrorKind::Authentication);
}
}