1324 lines
42 KiB
Rust
1324 lines
42 KiB
Rust
use crate::config::paths::Paths;
|
|
use crate::conversation::message::{Message, MessageContent};
|
|
use crate::model::ModelConfig;
|
|
use crate::providers::api_client::AuthProvider;
|
|
use crate::providers::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata};
|
|
use crate::providers::errors::ProviderError;
|
|
use crate::providers::formats::openai_responses::responses_api_to_streaming_message;
|
|
use crate::providers::openai_compatible::handle_status;
|
|
use crate::providers::retry::ProviderRetry;
|
|
use crate::session_context::SESSION_ID_HEADER;
|
|
use anyhow::{anyhow, Result};
|
|
use async_stream::try_stream;
|
|
use async_trait::async_trait;
|
|
use axum::{extract::Query, response::Html, routing::get, Router};
|
|
use base64::Engine;
|
|
use chrono::{DateTime, Utc};
|
|
use futures::future::BoxFuture;
|
|
use futures::{StreamExt, TryStreamExt};
|
|
use jsonwebtoken::jwk::JwkSet;
|
|
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
|
|
use reqwest::header::{HeaderName, HeaderValue};
|
|
use rmcp::model::{RawContent, Role, Tool};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use sha2::Digest;
|
|
use std::io;
|
|
use std::net::SocketAddr;
|
|
use std::ops::Deref;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, LazyLock};
|
|
use tokio::pin;
|
|
use tokio::sync::{oneshot, Mutex as TokioMutex};
|
|
use tokio_util::codec::{FramedRead, LinesCodec};
|
|
use tokio_util::io::StreamReader;
|
|
|
|
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
const ISSUER: &str = "https://auth.openai.com";
|
|
const CODEX_API_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex";
|
|
const OAUTH_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"];
|
|
// Canonical localhost callback port for Codex OAuth (default localhost:1455 per OpenAI docs).
|
|
// https://developers.openai.com/codex/auth/
|
|
const OAUTH_PORT: u16 = 1455;
|
|
// Allow time for users to complete the browser-based OAuth flow.
|
|
const OAUTH_TIMEOUT_SECS: u64 = 300;
|
|
const HTML_AUTO_CLOSE_TIMEOUT_MS: u64 = 2000;
|
|
|
|
const CHATGPT_CODEX_PROVIDER_NAME: &str = "chatgpt_codex";
|
|
pub const CHATGPT_CODEX_DEFAULT_MODEL: &str = "gpt-5.3-codex";
|
|
|
|
#[derive(Debug)]
|
|
pub struct ChatGptCodexModelAttrs {
|
|
pub name: &'static str,
|
|
pub reasoning_levels: &'static [&'static str],
|
|
}
|
|
|
|
pub const CHATGPT_CODEX_KNOWN_MODELS: &[ChatGptCodexModelAttrs] = &[
|
|
ChatGptCodexModelAttrs {
|
|
name: "gpt-5.4",
|
|
reasoning_levels: &["low", "medium", "high", "xhigh"],
|
|
},
|
|
ChatGptCodexModelAttrs {
|
|
name: "gpt-5.3-codex",
|
|
reasoning_levels: &["low", "medium", "high", "xhigh"],
|
|
},
|
|
];
|
|
|
|
const CHATGPT_CODEX_DOC_URL: &str = "https://openai.com/chatgpt";
|
|
|
|
const DEFAULT_REASONING_LEVELS: &[&str] = &["medium", "high"];
|
|
|
|
pub fn reasoning_levels_for_model(model_name: &str) -> &'static [&'static str] {
|
|
CHATGPT_CODEX_KNOWN_MODELS
|
|
.iter()
|
|
.find(|m| m.name == model_name)
|
|
.map(|m| m.reasoning_levels)
|
|
.unwrap_or(DEFAULT_REASONING_LEVELS)
|
|
}
|
|
|
|
fn known_model_names() -> Vec<&'static str> {
|
|
CHATGPT_CODEX_KNOWN_MODELS.iter().map(|m| m.name).collect()
|
|
}
|
|
|
|
const GPT_53_CODEX_TOOL_PREAMBLE: &str = "\
|
|
You are a coding agent. You have access to tools to accomplish tasks. \
|
|
Always use your tools to fulfill requests - do not just describe what you would do. \
|
|
Keep going until the query is completely resolved before yielding back to the user. \
|
|
Autonomously resolve the query using the tools available to you. \
|
|
Do NOT guess or make up an answer. \
|
|
Before making tool calls, send a brief message explaining what you're about to do.";
|
|
|
|
#[derive(Debug)]
|
|
struct ChatGptCodexAuthState {
|
|
oauth_mutex: TokioMutex<()>,
|
|
jwks_cache: TokioMutex<Option<JwkSet>>,
|
|
}
|
|
|
|
impl ChatGptCodexAuthState {
|
|
fn new() -> Self {
|
|
Self {
|
|
oauth_mutex: TokioMutex::new(()),
|
|
jwks_cache: TokioMutex::new(None),
|
|
}
|
|
}
|
|
|
|
fn instance() -> Arc<Self> {
|
|
Arc::clone(&CHATGPT_CODEX_AUTH_STATE)
|
|
}
|
|
}
|
|
|
|
static CHATGPT_CODEX_AUTH_STATE: LazyLock<Arc<ChatGptCodexAuthState>> =
|
|
LazyLock::new(|| Arc::new(ChatGptCodexAuthState::new()));
|
|
|
|
fn build_input_items(messages: &[Message]) -> Result<Vec<Value>> {
|
|
let mut items = Vec::new();
|
|
|
|
for message in messages {
|
|
let role = match message.role {
|
|
Role::User => Some("user"),
|
|
Role::Assistant => Some("assistant"),
|
|
};
|
|
let mut content_items: Vec<Value> = Vec::new();
|
|
|
|
let flush_text = |items: &mut Vec<Value>, role: Option<&str>, content: &mut Vec<Value>| {
|
|
if let Some(role) = role {
|
|
if !content.is_empty() {
|
|
items.push(json!({ "role": role, "content": std::mem::take(content) }));
|
|
}
|
|
} else {
|
|
content.clear();
|
|
}
|
|
};
|
|
|
|
for content in &message.content {
|
|
match content {
|
|
MessageContent::Text(text) => {
|
|
if !text.text.is_empty() {
|
|
let content_type = if message.role == Role::Assistant {
|
|
"output_text"
|
|
} else {
|
|
"input_text"
|
|
};
|
|
content_items.push(json!({ "type": content_type, "text": text.text }));
|
|
}
|
|
}
|
|
MessageContent::Image(img) => {
|
|
content_items.push(json!({
|
|
"type": "input_image",
|
|
"image_url": format!("data:{};base64,{}", img.mime_type, img.data),
|
|
}));
|
|
}
|
|
MessageContent::ToolRequest(request) => {
|
|
flush_text(&mut items, role, &mut content_items);
|
|
if let Ok(tool_call) = &request.tool_call {
|
|
let arguments_str = match tool_call.arguments.as_ref() {
|
|
Some(args) => serde_json::to_string(args)?,
|
|
None => "{}".to_string(),
|
|
};
|
|
items.push(json!({
|
|
"type": "function_call",
|
|
"call_id": request.id,
|
|
"name": tool_call.name,
|
|
"arguments": arguments_str
|
|
}));
|
|
}
|
|
}
|
|
MessageContent::ToolResponse(response) => {
|
|
flush_text(&mut items, role, &mut content_items);
|
|
match &response.tool_result {
|
|
Ok(contents) => {
|
|
let text_content: Vec<String> = contents
|
|
.content
|
|
.iter()
|
|
.filter_map(|c| {
|
|
if let RawContent::Text(t) = c.deref() {
|
|
Some(t.text.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
if !text_content.is_empty() {
|
|
items.push(json!({
|
|
"type": "function_call_output",
|
|
"call_id": response.id,
|
|
"output": text_content.join("\n")
|
|
}));
|
|
}
|
|
}
|
|
Err(error_data) => {
|
|
items.push(json!({
|
|
"type": "function_call_output",
|
|
"call_id": response.id,
|
|
"output": format!("Error: {}", error_data.message)
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
flush_text(&mut items, role, &mut content_items);
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
fn get_reasoning_effort(model_name: &str) -> String {
|
|
let config = crate::config::Config::global();
|
|
let effort = config
|
|
.get_chatgpt_codex_reasoning_effort()
|
|
.map(String::from)
|
|
.unwrap_or_else(|_| "medium".to_string());
|
|
|
|
let valid_levels = reasoning_levels_for_model(model_name);
|
|
if valid_levels.contains(&effort.as_str()) {
|
|
effort
|
|
} else {
|
|
tracing::warn!(
|
|
"Invalid CHATGPT_CODEX_REASONING_EFFORT '{}' for model '{}', using 'medium'",
|
|
effort,
|
|
model_name
|
|
);
|
|
"medium".to_string()
|
|
}
|
|
}
|
|
|
|
fn create_codex_request(
|
|
model_config: &ModelConfig,
|
|
system: &str,
|
|
messages: &[Message],
|
|
tools: &[Tool],
|
|
) -> Result<Value> {
|
|
let input_items = build_input_items(messages)?;
|
|
let reasoning_effort = get_reasoning_effort(&model_config.model_name);
|
|
|
|
let instructions = match model_config.model_name.as_str() {
|
|
"gpt-5.3-codex" => format!("{GPT_53_CODEX_TOOL_PREAMBLE}\n\n{system}"),
|
|
_ => system.to_string(),
|
|
};
|
|
|
|
let mut payload = json!({
|
|
"model": model_config.model_name,
|
|
"input": input_items,
|
|
"store": false,
|
|
"reasoning": {"effort": reasoning_effort},
|
|
"instructions": instructions,
|
|
});
|
|
|
|
let payload_obj = payload
|
|
.as_object_mut()
|
|
.ok_or_else(|| anyhow!("Codex payload must be a JSON object"))?;
|
|
|
|
if !tools.is_empty() {
|
|
let tools_spec: Vec<Value> = tools
|
|
.iter()
|
|
.map(|tool| {
|
|
json!({
|
|
"type": "function",
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"parameters": tool.input_schema,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
payload_obj.insert("tools".to_string(), json!(tools_spec));
|
|
payload_obj.insert("tool_choice".to_string(), json!("auto"));
|
|
payload_obj.insert("parallel_tool_calls".to_string(), json!(true));
|
|
}
|
|
|
|
if let Some(temp) = model_config.temperature {
|
|
payload_obj.insert("temperature".to_string(), json!(temp));
|
|
}
|
|
|
|
Ok(payload)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct TokenData {
|
|
access_token: String,
|
|
refresh_token: String,
|
|
id_token: Option<String>,
|
|
expires_at: DateTime<Utc>,
|
|
account_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TokenCache {
|
|
cache_path: PathBuf,
|
|
}
|
|
|
|
fn get_cache_path() -> PathBuf {
|
|
Paths::in_config_dir("chatgpt_codex/tokens.json")
|
|
}
|
|
|
|
impl TokenCache {
|
|
fn new() -> Self {
|
|
let cache_path = get_cache_path();
|
|
if let Some(parent) = cache_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
Self { cache_path }
|
|
}
|
|
|
|
fn load(&self) -> Option<TokenData> {
|
|
if let Ok(contents) = std::fs::read_to_string(&self.cache_path) {
|
|
serde_json::from_str(&contents).ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn save(&self, token_data: &TokenData) -> Result<()> {
|
|
if let Some(parent) = self.cache_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let contents = serde_json::to_string(token_data)?;
|
|
std::fs::write(&self.cache_path, contents)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn clear(&self) {
|
|
let _ = std::fs::remove_file(&self.cache_path);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct JwtClaims {
|
|
chatgpt_account_id: Option<String>,
|
|
#[serde(rename = "https://api.openai.com/auth")]
|
|
auth_claims: Option<AuthClaims>,
|
|
organizations: Option<Vec<OrgInfo>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AuthClaims {
|
|
chatgpt_account_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OrgInfo {
|
|
id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OidcConfiguration {
|
|
jwks_uri: String,
|
|
}
|
|
|
|
async fn fetch_jwks_for(issuer: &str) -> Result<JwkSet> {
|
|
let client = reqwest::Client::new();
|
|
let config_url = format!("{}/.well-known/openid-configuration", issuer);
|
|
let config = client
|
|
.get(config_url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json::<OidcConfiguration>()
|
|
.await?;
|
|
|
|
let jwks = client
|
|
.get(config.jwks_uri)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json::<JwkSet>()
|
|
.await?;
|
|
|
|
Ok(jwks)
|
|
}
|
|
|
|
async fn get_jwks(state: &ChatGptCodexAuthState) -> Result<JwkSet> {
|
|
let mut cache = state.jwks_cache.lock().await;
|
|
if let Some(jwks) = cache.clone() {
|
|
return Ok(jwks);
|
|
}
|
|
let jwks = fetch_jwks_for(ISSUER).await?;
|
|
*cache = Some(jwks.clone());
|
|
Ok(jwks)
|
|
}
|
|
|
|
fn parse_jwt_claims_with_jwks(token: &str, jwks: &JwkSet) -> Result<JwtClaims> {
|
|
let header = decode_header(token)?;
|
|
let kid = header
|
|
.kid
|
|
.ok_or_else(|| anyhow!("JWT header missing kid"))?;
|
|
let jwk = jwks
|
|
.find(&kid)
|
|
.ok_or_else(|| anyhow!("JWT signing key not found"))?;
|
|
let decoding_key = DecodingKey::from_jwk(jwk)?;
|
|
|
|
let mut validation = Validation::new(header.alg);
|
|
validation.validate_aud = false;
|
|
|
|
let token_data = decode::<JwtClaims>(token, &decoding_key, &validation)?;
|
|
Ok(token_data.claims)
|
|
}
|
|
|
|
fn parse_jwt_claims_unverified(token: &str) -> Option<JwtClaims> {
|
|
let parts: Vec<&str> = token.split('.').collect();
|
|
if parts.len() != 3 {
|
|
return None;
|
|
}
|
|
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
.decode(parts[1])
|
|
.ok()?;
|
|
serde_json::from_slice(&payload).ok()
|
|
}
|
|
|
|
async fn parse_jwt_claims(token: &str, state: &ChatGptCodexAuthState) -> Option<JwtClaims> {
|
|
if let Ok(jwks) = get_jwks(state).await {
|
|
if let Ok(claims) = parse_jwt_claims_with_jwks(token, &jwks) {
|
|
return Some(claims);
|
|
}
|
|
}
|
|
parse_jwt_claims_unverified(token)
|
|
}
|
|
|
|
fn account_id_from_claims(claims: &JwtClaims) -> Option<String> {
|
|
if let Some(id) = claims.chatgpt_account_id.as_ref() {
|
|
return Some(id.clone());
|
|
}
|
|
if let Some(auth) = claims.auth_claims.as_ref() {
|
|
if let Some(id) = auth.chatgpt_account_id.as_ref() {
|
|
return Some(id.clone());
|
|
}
|
|
}
|
|
if let Some(orgs) = claims.organizations.as_ref() {
|
|
if let Some(org) = orgs.first() {
|
|
return Some(org.id.clone());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn extract_account_id(
|
|
token_data: &TokenData,
|
|
state: &ChatGptCodexAuthState,
|
|
) -> Option<String> {
|
|
if let Some(id_token) = token_data.id_token.as_deref() {
|
|
if let Some(claims) = parse_jwt_claims(id_token, state).await {
|
|
if let Some(account_id) = account_id_from_claims(&claims) {
|
|
return Some(account_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
parse_jwt_claims(&token_data.access_token, state)
|
|
.await
|
|
.and_then(|claims| account_id_from_claims(&claims))
|
|
}
|
|
|
|
struct PkceChallenge {
|
|
verifier: String,
|
|
challenge: String,
|
|
}
|
|
|
|
fn generate_pkce() -> PkceChallenge {
|
|
let verifier = nanoid::nanoid!(43);
|
|
let digest = sha2::Sha256::digest(verifier.as_bytes());
|
|
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
|
|
PkceChallenge {
|
|
verifier,
|
|
challenge,
|
|
}
|
|
}
|
|
|
|
fn generate_state() -> String {
|
|
nanoid::nanoid!(32)
|
|
}
|
|
|
|
fn build_authorize_url(redirect_uri: &str, pkce: &PkceChallenge, state: &str) -> Result<String> {
|
|
let scopes = OAUTH_SCOPES.join(" ");
|
|
let params = [
|
|
("response_type", "code"),
|
|
("client_id", CLIENT_ID),
|
|
("redirect_uri", redirect_uri),
|
|
("scope", &scopes),
|
|
("code_challenge", &pkce.challenge),
|
|
("code_challenge_method", "S256"),
|
|
("id_token_add_organizations", "true"),
|
|
("codex_cli_simplified_flow", "true"),
|
|
("state", state),
|
|
("originator", "goose"),
|
|
];
|
|
let query = serde_urlencoded::to_string(params)?;
|
|
Ok(format!("{}/oauth/authorize?{}", ISSUER, query))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TokenResponse {
|
|
access_token: String,
|
|
refresh_token: String,
|
|
id_token: Option<String>,
|
|
expires_in: Option<i64>,
|
|
}
|
|
|
|
async fn exchange_code_for_tokens_with_issuer(
|
|
issuer: &str,
|
|
code: &str,
|
|
redirect_uri: &str,
|
|
pkce: &PkceChallenge,
|
|
) -> Result<TokenResponse> {
|
|
let client = reqwest::Client::new();
|
|
let params = [
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("client_id", CLIENT_ID),
|
|
("code_verifier", &pkce.verifier),
|
|
];
|
|
|
|
let resp = client
|
|
.post(format!("{}/oauth/token", issuer))
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.form(¶ms)
|
|
.send()
|
|
.await?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(anyhow!("Token exchange failed ({}): {}", status, text));
|
|
}
|
|
|
|
Ok(resp.json().await?)
|
|
}
|
|
|
|
async fn refresh_access_token_with_issuer(
|
|
issuer: &str,
|
|
refresh_token: &str,
|
|
) -> Result<TokenResponse> {
|
|
let client = reqwest::Client::new();
|
|
let params = [
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", refresh_token),
|
|
("client_id", CLIENT_ID),
|
|
];
|
|
|
|
let resp = client
|
|
.post(format!("{}/oauth/token", issuer))
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.form(¶ms)
|
|
.send()
|
|
.await?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(anyhow!("Token refresh failed ({}): {}", status, text));
|
|
}
|
|
|
|
Ok(resp.json().await?)
|
|
}
|
|
|
|
const HTML_SUCCESS_TEMPLATE: &str = r#"<!doctype html>
|
|
<html>
|
|
<head>
|
|
<title>goose - ChatGPT Authorization Successful</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
margin: 0;
|
|
background: #131010;
|
|
color: #f1ecec;
|
|
}
|
|
.container { text-align: center; padding: 2rem; }
|
|
h1 { color: #f1ecec; margin-bottom: 1rem; }
|
|
p { color: #b7b1b1; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Authorization Successful</h1>
|
|
<p>You can close this window and return to goose.</p>
|
|
</div>
|
|
<script>const AUTO_CLOSE_TIMEOUT_MS = __AUTO_CLOSE_TIMEOUT_MS__; setTimeout(() => window.close(), AUTO_CLOSE_TIMEOUT_MS)</script>
|
|
</body>
|
|
</html>"#;
|
|
|
|
fn html_success() -> String {
|
|
HTML_SUCCESS_TEMPLATE.replace(
|
|
"__AUTO_CLOSE_TIMEOUT_MS__",
|
|
&HTML_AUTO_CLOSE_TIMEOUT_MS.to_string(),
|
|
)
|
|
}
|
|
|
|
fn html_error(error: &str) -> String {
|
|
let safe_error = v_htmlescape::escape(error).to_string();
|
|
format!(
|
|
r#"<!doctype html>
|
|
<html>
|
|
<head>
|
|
<title>goose - ChatGPT Authorization Failed</title>
|
|
<style>
|
|
body {{
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
margin: 0;
|
|
background: #131010;
|
|
color: #f1ecec;
|
|
}}
|
|
.container {{ text-align: center; padding: 2rem; }}
|
|
h1 {{ color: #fc533a; margin-bottom: 1rem; }}
|
|
p {{ color: #b7b1b1; }}
|
|
.error {{
|
|
color: #ff917b;
|
|
font-family: monospace;
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background: #3c140d;
|
|
border-radius: 0.5rem;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Authorization Failed</h1>
|
|
<p>An error occurred during authorization.</p>
|
|
<div class="error">{}</div>
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
safe_error
|
|
)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CallbackParams {
|
|
code: Option<String>,
|
|
state: Option<String>,
|
|
error: Option<String>,
|
|
error_description: Option<String>,
|
|
}
|
|
|
|
fn oauth_callback_router(
|
|
expected_state: String,
|
|
tx: Arc<TokioMutex<Option<oneshot::Sender<Result<String>>>>>,
|
|
) -> Router {
|
|
Router::new().route(
|
|
"/auth/callback",
|
|
get(move |Query(params): Query<CallbackParams>| {
|
|
let tx = tx.clone();
|
|
let expected = expected_state.clone();
|
|
async move {
|
|
if let Some(error) = params.error {
|
|
let msg = params.error_description.unwrap_or(error);
|
|
if let Some(sender) = tx.lock().await.take() {
|
|
let _ = sender.send(Err(anyhow!("{}", msg)));
|
|
}
|
|
return Html(html_error(&msg));
|
|
}
|
|
|
|
let code = match params.code {
|
|
Some(c) => c,
|
|
None => {
|
|
let msg = "Missing authorization code";
|
|
if let Some(sender) = tx.lock().await.take() {
|
|
let _ = sender.send(Err(anyhow!("{}", msg)));
|
|
}
|
|
return Html(html_error(msg));
|
|
}
|
|
};
|
|
|
|
if params.state.as_deref() != Some(&expected) {
|
|
let msg = "Invalid state - potential CSRF attack";
|
|
if let Some(sender) = tx.lock().await.take() {
|
|
let _ = sender.send(Err(anyhow!("{}", msg)));
|
|
}
|
|
return Html(html_error(msg));
|
|
}
|
|
|
|
if let Some(sender) = tx.lock().await.take() {
|
|
let _ = sender.send(Ok(code));
|
|
}
|
|
Html(html_success())
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
async fn spawn_oauth_server(app: Router) -> Result<tokio::task::JoinHandle<()>> {
|
|
let addr = SocketAddr::from(([127, 0, 0, 1], OAUTH_PORT));
|
|
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
|
|
if e.kind() == io::ErrorKind::AddrInUse {
|
|
anyhow!(
|
|
"OAuth callback server failed to bind to {}: port {} is already in use. \
|
|
Please stop the process using this port and try again.",
|
|
addr,
|
|
OAUTH_PORT
|
|
)
|
|
} else {
|
|
anyhow!("OAuth callback server failed to bind to {}: {}", addr, e)
|
|
}
|
|
})?;
|
|
Ok(tokio::spawn(async move {
|
|
let server = axum::serve(listener, app);
|
|
let _ = server.await;
|
|
}))
|
|
}
|
|
|
|
struct ServerHandleGuard(Option<tokio::task::JoinHandle<()>>);
|
|
|
|
impl ServerHandleGuard {
|
|
fn new(handle: tokio::task::JoinHandle<()>) -> Self {
|
|
Self(Some(handle))
|
|
}
|
|
|
|
fn abort(&mut self) {
|
|
if let Some(handle) = self.0.take() {
|
|
handle.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ServerHandleGuard {
|
|
fn drop(&mut self) {
|
|
self.abort();
|
|
}
|
|
}
|
|
|
|
async fn wait_for_oauth_code(rx: oneshot::Receiver<Result<String>>) -> Result<String> {
|
|
let code_result =
|
|
tokio::time::timeout(std::time::Duration::from_secs(OAUTH_TIMEOUT_SECS), rx).await;
|
|
code_result
|
|
.map_err(|_| anyhow!("OAuth flow timed out"))??
|
|
.map_err(|e| anyhow!("OAuth callback error: {}", e))
|
|
}
|
|
|
|
async fn perform_oauth_flow(auth_state: &ChatGptCodexAuthState) -> Result<TokenData> {
|
|
let _guard = auth_state.oauth_mutex.try_lock().map_err(|_| {
|
|
anyhow!("Another OAuth flow is already in progress; please try again later")
|
|
})?;
|
|
|
|
let pkce = generate_pkce();
|
|
let csrf_state = generate_state();
|
|
let redirect_uri = format!("http://localhost:{}/auth/callback", OAUTH_PORT);
|
|
let auth_url = build_authorize_url(&redirect_uri, &pkce, &csrf_state)?;
|
|
|
|
let (tx, rx) = oneshot::channel::<Result<String>>();
|
|
let tx = Arc::new(TokioMutex::new(Some(tx)));
|
|
let app = oauth_callback_router(csrf_state.clone(), tx);
|
|
let server_handle = spawn_oauth_server(app).await?;
|
|
let mut server_guard = ServerHandleGuard::new(server_handle);
|
|
|
|
if webbrowser::open(&auth_url).is_err() {
|
|
tracing::info!("Please open this URL in your browser:\n{}", auth_url);
|
|
}
|
|
|
|
let code_result = wait_for_oauth_code(rx).await;
|
|
server_guard.abort();
|
|
let code = code_result?;
|
|
|
|
let tokens = exchange_code_for_tokens_with_issuer(ISSUER, &code, &redirect_uri, &pkce).await?;
|
|
|
|
let expires_at = Utc::now() + chrono::Duration::seconds(tokens.expires_in.unwrap_or(3600));
|
|
|
|
let mut token_data = TokenData {
|
|
access_token: tokens.access_token,
|
|
refresh_token: tokens.refresh_token,
|
|
id_token: tokens.id_token,
|
|
expires_at,
|
|
account_id: None,
|
|
};
|
|
|
|
token_data.account_id = extract_account_id(&token_data, auth_state).await;
|
|
|
|
Ok(token_data)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ChatGptCodexAuthProvider {
|
|
cache: TokenCache,
|
|
state: Arc<ChatGptCodexAuthState>,
|
|
}
|
|
|
|
impl ChatGptCodexAuthProvider {
|
|
fn new(state: Arc<ChatGptCodexAuthState>) -> Self {
|
|
Self {
|
|
cache: TokenCache::new(),
|
|
state,
|
|
}
|
|
}
|
|
|
|
fn clear_cached_tokens(&self) {
|
|
self.cache.clear();
|
|
}
|
|
|
|
async fn get_valid_token(&self) -> Result<TokenData> {
|
|
if let Some(mut token_data) = self.cache.load() {
|
|
if token_data.expires_at > Utc::now() + chrono::Duration::seconds(60) {
|
|
return Ok(token_data);
|
|
}
|
|
|
|
tracing::debug!("Token expired, attempting refresh");
|
|
match refresh_access_token_with_issuer(ISSUER, &token_data.refresh_token).await {
|
|
Ok(new_tokens) => {
|
|
token_data.access_token = new_tokens.access_token;
|
|
token_data.refresh_token = new_tokens.refresh_token;
|
|
if new_tokens.id_token.is_some() {
|
|
token_data.id_token = new_tokens.id_token;
|
|
}
|
|
token_data.expires_at = Utc::now()
|
|
+ chrono::Duration::seconds(new_tokens.expires_in.unwrap_or(3600));
|
|
if token_data.account_id.is_none() {
|
|
token_data.account_id =
|
|
extract_account_id(&token_data, self.state.as_ref()).await;
|
|
}
|
|
self.cache.save(&token_data)?;
|
|
tracing::info!("Token refreshed successfully");
|
|
return Ok(token_data);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Token refresh failed, will re-authenticate: {}", e);
|
|
self.cache.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!("Starting OAuth flow for ChatGPT Codex");
|
|
let token_data = perform_oauth_flow(self.state.as_ref()).await?;
|
|
self.cache.save(&token_data)?;
|
|
Ok(token_data)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AuthProvider for ChatGptCodexAuthProvider {
|
|
async fn get_auth_header(&self) -> Result<(String, String)> {
|
|
let token_data = self.get_valid_token().await?;
|
|
Ok((
|
|
"Authorization".to_string(),
|
|
format!("Bearer {}", token_data.access_token),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
pub struct ChatGptCodexProvider {
|
|
#[serde(skip)]
|
|
auth_provider: Arc<ChatGptCodexAuthProvider>,
|
|
model: ModelConfig,
|
|
#[serde(skip)]
|
|
name: String,
|
|
}
|
|
|
|
impl ChatGptCodexProvider {
|
|
pub async fn cleanup() -> Result<()> {
|
|
TokenCache::new().clear();
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn from_env(model: ModelConfig) -> Result<Self> {
|
|
let auth_provider = Arc::new(ChatGptCodexAuthProvider::new(
|
|
ChatGptCodexAuthState::instance(),
|
|
));
|
|
|
|
Ok(Self {
|
|
auth_provider,
|
|
model,
|
|
name: CHATGPT_CODEX_PROVIDER_NAME.to_string(),
|
|
})
|
|
}
|
|
|
|
async fn post_streaming(
|
|
&self,
|
|
session_id: Option<&str>,
|
|
payload: &Value,
|
|
) -> Result<reqwest::Response, ProviderError> {
|
|
let token_data = self
|
|
.auth_provider
|
|
.get_valid_token()
|
|
.await
|
|
.map_err(|e| ProviderError::Authentication(e.to_string()))?;
|
|
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
if let Some(account_id) = &token_data.account_id {
|
|
headers.insert(
|
|
reqwest::header::HeaderName::from_static("chatgpt-account-id"),
|
|
reqwest::header::HeaderValue::from_str(account_id)
|
|
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?,
|
|
);
|
|
}
|
|
|
|
if let Some(session_id) = session_id.filter(|id| !id.is_empty()) {
|
|
headers.insert(
|
|
HeaderName::from_static(SESSION_ID_HEADER),
|
|
HeaderValue::from_str(session_id)
|
|
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?,
|
|
);
|
|
}
|
|
|
|
let client = reqwest::Client::new();
|
|
let response = client
|
|
.post(format!("{}/responses", CODEX_API_ENDPOINT))
|
|
.header(
|
|
"Authorization",
|
|
format!("Bearer {}", token_data.access_token),
|
|
)
|
|
.header("Content-Type", "application/json")
|
|
.headers(headers)
|
|
.json(payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
|
|
|
|
handle_status(response).await
|
|
}
|
|
}
|
|
|
|
impl ProviderDef for ChatGptCodexProvider {
|
|
type Provider = Self;
|
|
|
|
fn metadata() -> ProviderMetadata {
|
|
ProviderMetadata::new(
|
|
CHATGPT_CODEX_PROVIDER_NAME,
|
|
"ChatGPT Codex",
|
|
"Use your ChatGPT Plus/Pro subscription for GPT-5 Codex models via OAuth",
|
|
CHATGPT_CODEX_DEFAULT_MODEL,
|
|
known_model_names(),
|
|
CHATGPT_CODEX_DOC_URL,
|
|
vec![ConfigKey::new_oauth(
|
|
"CHATGPT_CODEX_TOKEN",
|
|
true,
|
|
true,
|
|
None,
|
|
false,
|
|
)],
|
|
)
|
|
}
|
|
|
|
fn from_env(
|
|
model: ModelConfig,
|
|
_extensions: Vec<crate::config::ExtensionConfig>,
|
|
) -> BoxFuture<'static, Result<Self::Provider>> {
|
|
Box::pin(Self::from_env(model))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for ChatGptCodexProvider {
|
|
fn get_name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
fn get_model_config(&self) -> ModelConfig {
|
|
self.model.clone()
|
|
}
|
|
|
|
async fn stream(
|
|
&self,
|
|
model_config: &ModelConfig,
|
|
session_id: &str,
|
|
system: &str,
|
|
messages: &[Message],
|
|
tools: &[Tool],
|
|
) -> Result<MessageStream, ProviderError> {
|
|
let mut payload = create_codex_request(model_config, system, messages, tools)
|
|
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?;
|
|
payload["stream"] = serde_json::Value::Bool(true);
|
|
|
|
let response = self
|
|
.with_retry(|| async {
|
|
let payload_clone = payload.clone();
|
|
self.post_streaming(Some(session_id), &payload_clone).await
|
|
})
|
|
.await?;
|
|
|
|
let stream = response.bytes_stream().map_err(io::Error::other);
|
|
|
|
Ok(Box::pin(try_stream! {
|
|
let stream_reader = StreamReader::new(stream);
|
|
let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from);
|
|
|
|
let message_stream = responses_api_to_streaming_message(framed);
|
|
pin!(message_stream);
|
|
while let Some(message) = message_stream.next().await {
|
|
let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?;
|
|
yield (message, usage);
|
|
}
|
|
}))
|
|
}
|
|
|
|
async fn configure_oauth(&self) -> Result<(), ProviderError> {
|
|
let previous_token = self.auth_provider.cache.load();
|
|
self.auth_provider.clear_cached_tokens();
|
|
|
|
let result = perform_oauth_flow(self.auth_provider.state.as_ref())
|
|
.await
|
|
.and_then(|token_data| self.auth_provider.cache.save(&token_data));
|
|
|
|
if let Err(e) = result {
|
|
if let Some(previous_token) = previous_token.as_ref() {
|
|
if self.auth_provider.cache.load().is_none() {
|
|
let _ = self.auth_provider.cache.save(previous_token);
|
|
}
|
|
}
|
|
return Err(ProviderError::Authentication(format!(
|
|
"OAuth flow failed: {}",
|
|
e
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
|
|
Ok(known_model_names().into_iter().map(String::from).collect())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::conversation::message::Message;
|
|
use goose_test_support::TEST_IMAGE_B64;
|
|
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
|
use rmcp::model::{CallToolRequestParams, CallToolResult, Content, ErrorCode, ErrorData};
|
|
use rmcp::object;
|
|
use test_case::test_case;
|
|
use wiremock::matchers::{body_string_contains, method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn input_kinds(payload: &Value) -> Vec<String> {
|
|
payload["input"]
|
|
.as_array()
|
|
.map(|items| {
|
|
items
|
|
.iter()
|
|
.map(|item| {
|
|
if let Some(role) = item.get("role").and_then(|r| r.as_str()) {
|
|
format!("message:{role}")
|
|
} else {
|
|
item.get("type")
|
|
.and_then(|t| t.as_str())
|
|
.unwrap_or("unknown")
|
|
.to_string()
|
|
}
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[test_case(
|
|
vec![
|
|
Message::user().with_text("user text"),
|
|
Message::assistant().with_text("assistant prelude").with_tool_request(
|
|
"call-1",
|
|
Ok(CallToolRequestParams::new("tool_name").with_arguments(object!({"param": "value"}))),
|
|
),
|
|
Message::user().with_tool_response(
|
|
"call-1",
|
|
Ok(CallToolResult::success(vec![Content::text("tool output")])),
|
|
),
|
|
Message::assistant().with_text("assistant follow-up"),
|
|
],
|
|
vec![
|
|
"message:user".to_string(),
|
|
"message:assistant".to_string(),
|
|
"function_call".to_string(),
|
|
"function_call_output".to_string(),
|
|
"message:assistant".to_string(),
|
|
];
|
|
"preserves order when assistant includes text"
|
|
)]
|
|
#[test_case(
|
|
vec![
|
|
Message::user().with_text("user text"),
|
|
Message::assistant().with_tool_request(
|
|
"call-1",
|
|
Ok(CallToolRequestParams::new("tool_name").with_arguments(object!({"param": "value"}))),
|
|
),
|
|
Message::user().with_tool_response(
|
|
"call-1",
|
|
Ok(CallToolResult::success(vec![Content::text("tool output")])),
|
|
),
|
|
Message::assistant().with_text("assistant follow-up"),
|
|
],
|
|
vec![
|
|
"message:user".to_string(),
|
|
"function_call".to_string(),
|
|
"function_call_output".to_string(),
|
|
"message:assistant".to_string(),
|
|
];
|
|
"skips empty assistant message and preserves tool order"
|
|
)]
|
|
#[test_case(
|
|
vec![
|
|
Message::user().with_text("user text"),
|
|
Message::assistant().with_tool_request(
|
|
"call-1",
|
|
Ok(CallToolRequestParams::new("tool_name").with_arguments(object!({"param": "value"}))),
|
|
),
|
|
Message::user().with_tool_response(
|
|
"call-1",
|
|
Err(ErrorData::new(ErrorCode::INTERNAL_ERROR, "boom", None)),
|
|
),
|
|
],
|
|
vec![
|
|
"message:user".to_string(),
|
|
"function_call".to_string(),
|
|
"function_call_output".to_string(),
|
|
];
|
|
"includes tool error output"
|
|
)]
|
|
#[test_case(
|
|
vec![
|
|
Message::user()
|
|
.with_text("describe this")
|
|
.with_image(TEST_IMAGE_B64, "image/png"),
|
|
],
|
|
vec![
|
|
"message:user".to_string(),
|
|
];
|
|
"image content included in user message"
|
|
)]
|
|
fn test_codex_input_order(messages: Vec<Message>, expected: Vec<String>) {
|
|
let items = build_input_items(&messages).unwrap();
|
|
let payload = json!({ "input": items });
|
|
let kinds = input_kinds(&payload);
|
|
assert_eq!(kinds, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_image_url_format() {
|
|
let messages = vec![Message::user().with_image(TEST_IMAGE_B64, "image/png")];
|
|
let items = build_input_items(&messages).unwrap();
|
|
// The image is inside the content array of the user message
|
|
let content = items[0]["content"].as_array().unwrap();
|
|
let image_item = &content[0];
|
|
assert_eq!(image_item["type"], "input_image");
|
|
let url = image_item["image_url"].as_str().unwrap();
|
|
assert!(
|
|
url.starts_with("data:image/png;base64,"),
|
|
"image_url should start with data:image/png;base64, but was: {}",
|
|
url
|
|
);
|
|
}
|
|
|
|
#[test_case(
|
|
JwtClaims {
|
|
chatgpt_account_id: Some("account-1".to_string()),
|
|
auth_claims: None,
|
|
organizations: None,
|
|
},
|
|
Some("account-1".to_string());
|
|
"uses top-level account id"
|
|
)]
|
|
#[test_case(
|
|
JwtClaims {
|
|
chatgpt_account_id: None,
|
|
auth_claims: Some(AuthClaims {
|
|
chatgpt_account_id: Some("account-2".to_string()),
|
|
}),
|
|
organizations: None,
|
|
},
|
|
Some("account-2".to_string());
|
|
"uses auth claims account id"
|
|
)]
|
|
#[test_case(
|
|
JwtClaims {
|
|
chatgpt_account_id: None,
|
|
auth_claims: None,
|
|
organizations: Some(vec![OrgInfo {
|
|
id: "org-1".to_string(),
|
|
}]),
|
|
},
|
|
Some("org-1".to_string());
|
|
"falls back to first organization"
|
|
)]
|
|
fn test_account_id_from_claims(claims: JwtClaims, expected: Option<String>) {
|
|
assert_eq!(account_id_from_claims(&claims), expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_exchange_code_for_tokens() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.and(body_string_contains("grant_type=authorization_code"))
|
|
.and(body_string_contains("code=code-123"))
|
|
.and(body_string_contains(
|
|
"redirect_uri=http%3A%2F%2Flocalhost%2Fcallback",
|
|
))
|
|
.and(body_string_contains("code_verifier=verifier-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "access-1",
|
|
"refresh_token": "refresh-1",
|
|
"id_token": "id-1",
|
|
"expires_in": 3600
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let pkce = PkceChallenge {
|
|
verifier: "verifier-123".to_string(),
|
|
challenge: "challenge-123".to_string(),
|
|
};
|
|
let tokens = exchange_code_for_tokens_with_issuer(
|
|
&server.uri(),
|
|
"code-123",
|
|
"http://localhost/callback",
|
|
&pkce,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(tokens.access_token, "access-1");
|
|
assert_eq!(tokens.refresh_token, "refresh-1");
|
|
assert_eq!(tokens.id_token.as_deref(), Some("id-1"));
|
|
assert_eq!(tokens.expires_in, Some(3600));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_refresh_access_token() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.and(body_string_contains("grant_type=refresh_token"))
|
|
.and(body_string_contains("refresh_token=refresh-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "access-2",
|
|
"refresh_token": "refresh-2",
|
|
"id_token": "id-2",
|
|
"expires_in": 1800
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let tokens = refresh_access_token_with_issuer(&server.uri(), "refresh-123")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(tokens.access_token, "access-2");
|
|
assert_eq!(tokens.refresh_token, "refresh-2");
|
|
assert_eq!(tokens.id_token.as_deref(), Some("id-2"));
|
|
assert_eq!(tokens.expires_in, Some(1800));
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TestClaims {
|
|
exp: usize,
|
|
chatgpt_account_id: Option<String>,
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parse_jwt_claims_verified_with_issuer() {
|
|
let server = MockServer::start().await;
|
|
let jwks_uri = format!("{}/jwks", server.uri());
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/openid-configuration"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"jwks_uri": jwks_uri
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let secret = "test-secret";
|
|
let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret);
|
|
Mock::given(method("GET"))
|
|
.and(path("/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"keys": [{
|
|
"kty": "oct",
|
|
"alg": "HS256",
|
|
"kid": "test-kid",
|
|
"k": key
|
|
}]
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut header = Header::new(Algorithm::HS256);
|
|
header.kid = Some("test-kid".to_string());
|
|
|
|
let claims = TestClaims {
|
|
exp: (Utc::now() + chrono::Duration::seconds(60)).timestamp() as usize,
|
|
chatgpt_account_id: Some("account-1".to_string()),
|
|
};
|
|
let token = jsonwebtoken::encode(
|
|
&header,
|
|
&claims,
|
|
&EncodingKey::from_secret(secret.as_bytes()),
|
|
)
|
|
.unwrap();
|
|
|
|
let jwks = fetch_jwks_for(&server.uri()).await.unwrap();
|
|
let claims = parse_jwt_claims_with_jwks(&token, &jwks).unwrap();
|
|
|
|
assert_eq!(claims.chatgpt_account_id.as_deref(), Some("account-1"));
|
|
}
|
|
|
|
#[test_case("unknown-model", &["medium", "high"]; "unknown model gets default reasoning levels")]
|
|
fn test_reasoning_levels_for_model(model: &str, expected: &[&str]) {
|
|
assert_eq!(reasoning_levels_for_model(model), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gpt53_preamble_injected() {
|
|
let model = ModelConfig::new("gpt-5.3-codex").unwrap();
|
|
let payload = create_codex_request(&model, "system prompt", &[], &[]).unwrap();
|
|
let instructions = payload["instructions"].as_str().unwrap();
|
|
assert!(instructions.contains(GPT_53_CODEX_TOOL_PREAMBLE));
|
|
assert!(instructions.contains("system prompt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_other_models_no_preamble() {
|
|
let model = ModelConfig::new("gpt-5.4").unwrap();
|
|
let payload = create_codex_request(&model, "system prompt", &[], &[]).unwrap();
|
|
let instructions = payload["instructions"].as_str().unwrap();
|
|
assert_eq!(instructions, "system prompt");
|
|
}
|
|
}
|