Merge remote-tracking branch 'ryserve/master'
# Conflicts: # app/src/ai/llms.rs
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user