mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(image): 接入 ChatGPT Web 生图反代
This commit is contained in:
@@ -94,12 +94,14 @@ pub use crate::request::specialized::{
|
||||
resolve_sync_spec as resolve_gemini_files_sync_spec, LocalGeminiFilesSpec,
|
||||
},
|
||||
image::{
|
||||
build_openai_image_provider_request_body, default_model_for_openai_image_operation,
|
||||
is_openai_image_stream_request, normalize_openai_image_request,
|
||||
openai_image_operation_from_path, resolve_requested_openai_image_model_for_request,
|
||||
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
|
||||
default_model_for_openai_image_operation, is_openai_image_stream_request,
|
||||
normalize_openai_image_request, openai_image_operation_from_path,
|
||||
resolve_requested_openai_image_model_for_request,
|
||||
resolve_stream_spec as resolve_local_image_stream_spec,
|
||||
resolve_sync_spec as resolve_local_image_sync_spec, LocalOpenAiImageSpec,
|
||||
NormalizedOpenAiImageRequest, OpenAiImageOperation, OpenAiImageResponseFormat,
|
||||
resolve_sync_spec as resolve_local_image_sync_spec, ChatGptWebImageRequestError,
|
||||
LocalOpenAiImageSpec, NormalizedOpenAiImageRequest, OpenAiImageOperation,
|
||||
OpenAiImageResponseFormat,
|
||||
},
|
||||
video::{
|
||||
resolve_sync_spec as resolve_local_video_sync_spec, LocalVideoCreateFamily,
|
||||
|
||||
@@ -83,6 +83,171 @@ pub struct NormalizedOpenAiImageRequest {
|
||||
user: Option<String>,
|
||||
}
|
||||
|
||||
pub const CHATGPT_WEB_IMAGE_MAX_AREA: u64 = 1_500_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChatGptWebImageRequestError {
|
||||
pub status_code: u16,
|
||||
pub error_type: &'static str,
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ChatGptWebImageRequestError {
|
||||
fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status_code: 400,
|
||||
error_type: "invalid_request_error",
|
||||
code: "chatgpt_web_image_unsupported",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_error_json(&self) -> Value {
|
||||
json!({
|
||||
"error": {
|
||||
"message": self.message,
|
||||
"type": self.error_type,
|
||||
"code": self.code,
|
||||
"param": Value::Null,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ChatGptWebRawImageFields {
|
||||
size: Option<String>,
|
||||
resolution: Option<String>,
|
||||
size_tier: Option<String>,
|
||||
ratio: Option<String>,
|
||||
aspect_ratio: Option<String>,
|
||||
web_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChatGptWebResolvedSize {
|
||||
size: String,
|
||||
ratio: String,
|
||||
best_effort: bool,
|
||||
}
|
||||
|
||||
pub fn build_chatgpt_web_image_request_body(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> Result<Value, ChatGptWebImageRequestError> {
|
||||
let request = normalize_openai_image_request(parts, body_json, body_base64).ok_or_else(|| {
|
||||
ChatGptWebImageRequestError::invalid_request(
|
||||
"ChatGPT-Web image proxy only supports OpenAI image requests with prompt, n=1, supported image inputs, and supported output options",
|
||||
)
|
||||
})?;
|
||||
if request.tool.contains_key("input_image_mask") {
|
||||
return Err(ChatGptWebImageRequestError::invalid_request(
|
||||
"ChatGPT-Web image proxy does not support mask inputs",
|
||||
));
|
||||
}
|
||||
|
||||
let raw_fields = resolve_chatgpt_web_raw_image_fields(parts, body_json, body_base64)?;
|
||||
let resolved_size = resolve_chatgpt_web_size(&raw_fields)?;
|
||||
let prompt = request
|
||||
.prompt
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(match request.operation {
|
||||
OpenAiImageOperation::Variation => "Create a faithful variation of the provided image.",
|
||||
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit => {
|
||||
"Generate a high quality image."
|
||||
}
|
||||
});
|
||||
let prompt = chatgpt_web_prompt_with_ratio(prompt, resolved_size.ratio.as_str());
|
||||
let mut image_urls = Vec::new();
|
||||
for image in &request.images {
|
||||
let Some(object) = image.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if object
|
||||
.get("file_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Err(ChatGptWebImageRequestError::invalid_request(
|
||||
"ChatGPT-Web image proxy does not support file_id image inputs; use URL, data URL, or multipart file inputs",
|
||||
));
|
||||
}
|
||||
if let Some(image_url) = object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
image_urls.push(Value::String(image_url.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let requested_model = request
|
||||
.requested_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_model_for_openai_image_operation(request.operation).to_string());
|
||||
let mut body = Map::new();
|
||||
body.insert(
|
||||
"operation".to_string(),
|
||||
Value::String(request.operation.as_str().to_string()),
|
||||
);
|
||||
body.insert("model".to_string(), Value::String(requested_model));
|
||||
body.insert(
|
||||
"web_model".to_string(),
|
||||
Value::String(
|
||||
raw_fields
|
||||
.web_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("gpt-5-5-thinking")
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
body.insert("prompt".to_string(), Value::String(prompt));
|
||||
body.insert("size".to_string(), Value::String(resolved_size.size));
|
||||
body.insert("ratio".to_string(), Value::String(resolved_size.ratio));
|
||||
body.insert(
|
||||
"size_best_effort".to_string(),
|
||||
Value::Bool(resolved_size.best_effort),
|
||||
);
|
||||
body.insert("images".to_string(), Value::Array(image_urls));
|
||||
body.insert("count".to_string(), Value::Number(Number::from(1)));
|
||||
if let Some(user) = request.user.as_ref() {
|
||||
body.insert("user".to_string(), Value::String(user.clone()));
|
||||
}
|
||||
if let Some(output_format) = request
|
||||
.summary_json
|
||||
.get("output_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
body.insert(
|
||||
"output_format".to_string(),
|
||||
Value::String(output_format.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(response_format) = request
|
||||
.summary_json
|
||||
.get("response_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
body.insert(
|
||||
"response_format".to_string(),
|
||||
Value::String(response_format.to_string()),
|
||||
);
|
||||
}
|
||||
Ok(Value::Object(body))
|
||||
}
|
||||
|
||||
pub fn is_openai_image_stream_request(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
@@ -117,6 +282,170 @@ pub fn openai_image_operation_from_path(path: &str) -> Option<OpenAiImageOperati
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_chatgpt_web_raw_image_fields(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> Result<ChatGptWebRawImageFields, ChatGptWebImageRequestError> {
|
||||
if let Some(body_base64) = body_base64 {
|
||||
let fields = parse_multipart_fields_from_base64(parts, body_base64).ok_or_else(|| {
|
||||
ChatGptWebImageRequestError::invalid_request(
|
||||
"ChatGPT-Web image proxy could not parse multipart image request",
|
||||
)
|
||||
})?;
|
||||
return Ok(ChatGptWebRawImageFields {
|
||||
size: find_multipart_text_field(&fields, "size"),
|
||||
resolution: find_multipart_text_field(&fields, "resolution"),
|
||||
size_tier: find_multipart_text_field(&fields, "size_tier"),
|
||||
ratio: find_multipart_text_field(&fields, "ratio"),
|
||||
aspect_ratio: find_multipart_text_field(&fields, "aspect_ratio"),
|
||||
web_model: find_multipart_text_field(&fields, "web_model"),
|
||||
});
|
||||
}
|
||||
|
||||
let Some(object) = body_json.as_object() else {
|
||||
return Ok(ChatGptWebRawImageFields::default());
|
||||
};
|
||||
Ok(ChatGptWebRawImageFields {
|
||||
size: json_text_field(object, "size"),
|
||||
resolution: json_text_field(object, "resolution"),
|
||||
size_tier: json_text_field(object, "size_tier"),
|
||||
ratio: json_text_field(object, "ratio"),
|
||||
aspect_ratio: json_text_field(object, "aspect_ratio"),
|
||||
web_model: json_text_field(object, "web_model"),
|
||||
})
|
||||
}
|
||||
|
||||
fn json_text_field(object: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| value.as_u64().map(|number| number.to_string()))
|
||||
.or_else(|| value.as_i64().map(|number| number.to_string()))
|
||||
})
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn resolve_chatgpt_web_size(
|
||||
fields: &ChatGptWebRawImageFields,
|
||||
) -> Result<ChatGptWebResolvedSize, ChatGptWebImageRequestError> {
|
||||
let tier = fields
|
||||
.resolution
|
||||
.as_deref()
|
||||
.or(fields.size_tier.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if let Some(tier) = tier {
|
||||
let normalized = tier.to_ascii_uppercase();
|
||||
if normalized != "1K" && normalized != "1" {
|
||||
return Err(unsupported_chatgpt_web_resolution_error());
|
||||
}
|
||||
}
|
||||
|
||||
let fallback_ratio = fields
|
||||
.ratio
|
||||
.as_deref()
|
||||
.or(fields.aspect_ratio.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("1:1");
|
||||
let explicit_size = fields
|
||||
.size
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let size = explicit_size
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| chatgpt_web_1k_size_for_ratio(fallback_ratio).to_string());
|
||||
let (width, height) = parse_image_size_or_default(size.as_str());
|
||||
if width.saturating_mul(height) > CHATGPT_WEB_IMAGE_MAX_AREA {
|
||||
return Err(unsupported_chatgpt_web_resolution_error());
|
||||
}
|
||||
let ratio = chatgpt_web_ratio_from_size(size.as_str(), fallback_ratio).to_string();
|
||||
let best_effort = explicit_size.is_some_and(|_| !chatgpt_web_is_exact_1k_size(size.as_str()));
|
||||
|
||||
Ok(ChatGptWebResolvedSize {
|
||||
size,
|
||||
ratio,
|
||||
best_effort,
|
||||
})
|
||||
}
|
||||
|
||||
fn unsupported_chatgpt_web_resolution_error() -> ChatGptWebImageRequestError {
|
||||
ChatGptWebImageRequestError::invalid_request(
|
||||
"ChatGPT-Web image proxy does not support the requested resolution; use resolution=1K, size_tier=1K, or a size area <= 1,500,000 pixels",
|
||||
)
|
||||
}
|
||||
|
||||
fn chatgpt_web_1k_size_for_ratio(ratio: &str) -> &'static str {
|
||||
match ratio.trim() {
|
||||
"3:2" => "1216x832",
|
||||
"2:3" => "832x1216",
|
||||
"4:3" => "1152x864",
|
||||
"3:4" => "864x1152",
|
||||
"5:4" => "1120x896",
|
||||
"4:5" => "896x1120",
|
||||
"16:9" => "1344x768",
|
||||
"9:16" => "768x1344",
|
||||
"21:9" => "1536x640",
|
||||
_ => "1024x1024",
|
||||
}
|
||||
}
|
||||
|
||||
fn chatgpt_web_ratio_from_size<'a>(size: &str, fallback: &'a str) -> &'a str {
|
||||
match size.trim() {
|
||||
"1024x1024" | "1248x1248" | "2480x2480" | "512x512" => "1:1",
|
||||
"1216x832" | "1536x1024" | "3056x2032" => "3:2",
|
||||
"832x1216" | "1024x1536" | "2032x3056" => "2:3",
|
||||
"1152x864" | "1440x1088" | "2880x2160" => "4:3",
|
||||
"864x1152" | "1088x1440" | "2160x2880" => "3:4",
|
||||
"1120x896" | "1392x1120" | "2784x2224" => "5:4",
|
||||
"896x1120" | "1120x1392" | "2224x2784" => "4:5",
|
||||
"1344x768" | "1664x928" | "3312x1872" => "16:9",
|
||||
"768x1344" | "928x1664" | "1872x3312" => "9:16",
|
||||
"1536x640" | "1904x816" | "3808x1632" => "21:9",
|
||||
_ => fallback.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
fn chatgpt_web_is_exact_1k_size(size: &str) -> bool {
|
||||
matches!(
|
||||
size.trim(),
|
||||
"1024x1024"
|
||||
| "1216x832"
|
||||
| "832x1216"
|
||||
| "1152x864"
|
||||
| "864x1152"
|
||||
| "1120x896"
|
||||
| "896x1120"
|
||||
| "1344x768"
|
||||
| "768x1344"
|
||||
| "1536x640"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_image_size_or_default(size: &str) -> (u64, u64) {
|
||||
let Some((width, height)) = size.trim().split_once('x') else {
|
||||
return (1024, 1024);
|
||||
};
|
||||
let width = width.trim().parse::<u64>().ok().filter(|value| *value > 0);
|
||||
let height = height.trim().parse::<u64>().ok().filter(|value| *value > 0);
|
||||
(width.unwrap_or(1024), height.unwrap_or(1024))
|
||||
}
|
||||
|
||||
fn chatgpt_web_prompt_with_ratio(prompt: &str, ratio: &str) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let ratio = ratio.trim();
|
||||
if ratio.is_empty() || ratio == "1:1" {
|
||||
return prompt.to_string();
|
||||
}
|
||||
format!("{prompt}\n\nSet the image aspect ratio to {ratio}.")
|
||||
}
|
||||
|
||||
pub fn resolve_requested_openai_image_model_for_request(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
@@ -804,9 +1133,9 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
build_openai_image_provider_request_body, is_openai_image_stream_request,
|
||||
normalize_openai_image_request, resolve_stream_spec, resolve_sync_spec,
|
||||
OpenAiImageOperation,
|
||||
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
|
||||
is_openai_image_stream_request, normalize_openai_image_request, resolve_stream_spec,
|
||||
resolve_sync_spec, OpenAiImageOperation,
|
||||
};
|
||||
use crate::request::standard::{
|
||||
apply_codex_openai_responses_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
|
||||
@@ -1124,4 +1453,100 @@ mod tests {
|
||||
Some("image_generation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_accepts_1k_tier_and_1024_size() {
|
||||
let parts = request_parts("/v1/images/generations", Some("application/json"));
|
||||
let by_tier = build_chatgpt_web_image_request_body(
|
||||
&parts,
|
||||
&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "draw",
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "16:9"
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.expect("1K should pass");
|
||||
assert_eq!(by_tier["size"], "1344x768");
|
||||
assert_eq!(by_tier["ratio"], "16:9");
|
||||
assert_eq!(by_tier["size_best_effort"], false);
|
||||
|
||||
let by_size = build_chatgpt_web_image_request_body(
|
||||
&parts,
|
||||
&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "draw",
|
||||
"size": "1024x1024"
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.expect("1024x1024 should pass");
|
||||
assert_eq!(by_size["size"], "1024x1024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_rejects_oversized_resolution_or_size() {
|
||||
let parts = request_parts("/v1/images/generations", Some("application/json"));
|
||||
|
||||
for body in [
|
||||
json!({"prompt":"draw","resolution":"2K"}),
|
||||
json!({"prompt":"draw","size_tier":"4K"}),
|
||||
json!({"prompt":"draw","size":"2048x2048"}),
|
||||
] {
|
||||
let err = build_chatgpt_web_image_request_body(&parts, &body, None)
|
||||
.expect_err("oversized request should fail");
|
||||
assert_eq!(err.status_code, 400);
|
||||
assert_eq!(err.error_type, "invalid_request_error");
|
||||
assert!(err.message.contains("ChatGPT-Web"));
|
||||
assert!(err.message.contains("resolution"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_accepts_smaller_size_as_best_effort() {
|
||||
let parts = request_parts("/v1/images/generations", Some("application/json"));
|
||||
let body = build_chatgpt_web_image_request_body(
|
||||
&parts,
|
||||
&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "draw",
|
||||
"size": "512x512"
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.expect("smaller size should pass");
|
||||
|
||||
assert_eq!(body["size"], "512x512");
|
||||
assert_eq!(body["ratio"], "1:1");
|
||||
assert_eq!(body["size_best_effort"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_rejects_file_id_and_mask_inputs() {
|
||||
let edit_parts = request_parts("/v1/images/edits", Some("application/json"));
|
||||
|
||||
let file_id_err = build_chatgpt_web_image_request_body(
|
||||
&edit_parts,
|
||||
&json!({
|
||||
"prompt": "edit",
|
||||
"image": {"file_id": "file_123"}
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.expect_err("file_id should not be supported");
|
||||
assert!(file_id_err.message.contains("file_id"));
|
||||
|
||||
let mask_err = build_chatgpt_web_image_request_body(
|
||||
&edit_parts,
|
||||
&json!({
|
||||
"prompt": "edit",
|
||||
"image": "data:image/png;base64,aW1hZ2U=",
|
||||
"mask": "data:image/png;base64,bWFzaw=="
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.expect_err("mask should not be supported");
|
||||
assert!(mask_err.message.contains("mask"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ pub use error::{ExecutionError, ExecutionErrorKind, ExecutionPhase};
|
||||
pub use frame::{StreamFrame, StreamFramePayload, StreamFrameType};
|
||||
pub use plan::{
|
||||
ExecutionPlan, ExecutionTimeouts, ProxySnapshot, RequestBody, ResolvedTransportProfile,
|
||||
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
||||
TRANSPORT_BACKEND_HYPER_RUSTLS, TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_AUTO,
|
||||
TRANSPORT_HTTP_MODE_HTTP1_ONLY, TRANSPORT_POOL_SCOPE_KEY,
|
||||
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
|
||||
EXECUTION_REQUEST_HTTP1_ONLY_HEADER, TRANSPORT_BACKEND_HYPER_RUSTLS,
|
||||
TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
|
||||
TRANSPORT_POOL_SCOPE_KEY,
|
||||
};
|
||||
pub use result::{ExecutionResult, ExecutionTelemetry, ResponseBody};
|
||||
pub use usage::{ExecutionStreamTerminalSummary, StandardizedUsage};
|
||||
|
||||
@@ -5,6 +5,8 @@ use serde_json::Value;
|
||||
|
||||
pub const EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER: &str = "x-aether-execution-follow-redirects";
|
||||
pub const EXECUTION_REQUEST_HTTP1_ONLY_HEADER: &str = "x-aether-execution-http1-only";
|
||||
pub const EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER: &str =
|
||||
"x-aether-execution-accept-invalid-certs";
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -504,7 +504,7 @@ CREATE TABLE IF NOT EXISTS ldap_configs (
|
||||
bind_dn TEXT NOT NULL,
|
||||
bind_password_encrypted TEXT,
|
||||
base_dn TEXT NOT NULL,
|
||||
user_search_filter TEXT NOT NULL DEFAULT '(uid={username})',
|
||||
user_search_filter VARCHAR(512) NOT NULL DEFAULT '(uid={username})',
|
||||
username_attr VARCHAR(50) NOT NULL DEFAULT 'uid',
|
||||
email_attr VARCHAR(50) NOT NULL DEFAULT 'mail',
|
||||
display_name_attr VARCHAR(50) NOT NULL DEFAULT 'cn',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS public.auth_modules (
|
||||
id character varying(36) PRIMARY KEY,
|
||||
module_type character varying(128) NOT NULL UNIQUE,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
config json DEFAULT '{}'::json NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS ldap_configs (
|
||||
bind_dn TEXT NOT NULL,
|
||||
bind_password_encrypted TEXT,
|
||||
base_dn TEXT NOT NULL,
|
||||
user_search_filter TEXT NOT NULL DEFAULT '(uid={username})',
|
||||
user_search_filter VARCHAR(512) NOT NULL DEFAULT '(uid={username})',
|
||||
username_attr VARCHAR(50) NOT NULL DEFAULT 'uid',
|
||||
email_attr VARCHAR(50) NOT NULL DEFAULT 'mail',
|
||||
display_name_attr VARCHAR(50) NOT NULL DEFAULT 'cn',
|
||||
|
||||
@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS ldap_configs (
|
||||
`bind_dn` LONGTEXT NOT NULL,
|
||||
`bind_password_encrypted` LONGTEXT,
|
||||
`base_dn` LONGTEXT NOT NULL,
|
||||
`user_search_filter` LONGTEXT NOT NULL DEFAULT '(uid={username})',
|
||||
`user_search_filter` VARCHAR(512) NOT NULL DEFAULT '(uid={username})',
|
||||
`username_attr` VARCHAR(50) NOT NULL DEFAULT 'uid',
|
||||
`email_attr` VARCHAR(50) NOT NULL DEFAULT 'mail',
|
||||
`display_name_attr` VARCHAR(50) NOT NULL DEFAULT 'cn',
|
||||
|
||||
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS public.ldap_configs (
|
||||
bind_dn text NOT NULL,
|
||||
bind_password_encrypted text,
|
||||
base_dn text NOT NULL,
|
||||
user_search_filter text DEFAULT '(uid={username})' NOT NULL,
|
||||
user_search_filter character varying(512) DEFAULT '(uid={username})' NOT NULL,
|
||||
username_attr character varying(50) DEFAULT 'uid' NOT NULL,
|
||||
email_attr character varying(50) DEFAULT 'mail' NOT NULL,
|
||||
display_name_attr character varying(50) DEFAULT 'cn' NOT NULL,
|
||||
|
||||
@@ -180,7 +180,8 @@ type = "long_text"
|
||||
|
||||
[[table.ldap_configs.columns]]
|
||||
name = "user_search_filter"
|
||||
type = "long_text"
|
||||
type = "text"
|
||||
length = 512
|
||||
default = "(uid={username})"
|
||||
|
||||
[[table.ldap_configs.columns]]
|
||||
|
||||
@@ -284,6 +284,18 @@ mod tests {
|
||||
let request_zero = format!("request-daily-zero-{suffix}");
|
||||
let request_outside = format!("request-daily-outside-{suffix}");
|
||||
let stale_ledger_id = format!("stale-ledger-{suffix}");
|
||||
let unique_offset = chrono::Utc::now()
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or_default()
|
||||
.rem_euclid(10_000_000);
|
||||
let window_start = 4_100_000_000_i64 + unique_offset * 1_000;
|
||||
let window_end = window_start + 200;
|
||||
let first_finalized_at = window_start;
|
||||
let last_finalized_at = window_start + 100;
|
||||
let zero_finalized_at = window_start + 150;
|
||||
let outside_finalized_at = window_end;
|
||||
let seed_created_at = window_start - 100;
|
||||
let aggregated_at = window_end + 100;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -309,19 +321,31 @@ INSERT INTO `usage` (
|
||||
cache_read_input_tokens, finalized_at, created_at_unix_ms, updated_at_unix_secs
|
||||
) VALUES
|
||||
(?, 'wrong-wallet', 'provider', 'model', 'completed', 'pending',
|
||||
1.25, 10, 20, 3, 4, 4099999900, 4099999900000, 4099999900),
|
||||
1.25, 10, 20, 3, 4, ?, ?, ?),
|
||||
(?, NULL, 'provider', 'model', 'completed', 'pending',
|
||||
2.00, 5, 7, 1, 2, 4099999901, 4099999901000, 4099999901),
|
||||
2.00, 5, 7, 1, 2, ?, ?, ?),
|
||||
(?, NULL, 'provider', 'model', 'completed', 'pending',
|
||||
0.00, 100, 100, 0, 0, 4099999902, 4099999902000, 4099999902),
|
||||
0.00, 100, 100, 0, 0, ?, ?, ?),
|
||||
(?, NULL, 'provider', 'model', 'completed', 'pending',
|
||||
9.00, 50, 50, 0, 0, 4099999903, 4099999903000, 4099999903)
|
||||
9.00, 50, 50, 0, 0, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&request_one)
|
||||
.bind(seed_created_at)
|
||||
.bind(seed_created_at * 1000)
|
||||
.bind(seed_created_at)
|
||||
.bind(&request_two)
|
||||
.bind(seed_created_at + 1)
|
||||
.bind((seed_created_at + 1) * 1000)
|
||||
.bind(seed_created_at + 1)
|
||||
.bind(&request_zero)
|
||||
.bind(seed_created_at + 2)
|
||||
.bind((seed_created_at + 2) * 1000)
|
||||
.bind(seed_created_at + 2)
|
||||
.bind(&request_outside)
|
||||
.bind(seed_created_at + 3)
|
||||
.bind((seed_created_at + 3) * 1000)
|
||||
.bind(seed_created_at + 3)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("usage should seed");
|
||||
@@ -331,20 +355,32 @@ INSERT INTO `usage` (
|
||||
INSERT INTO usage_settlement_snapshots (
|
||||
request_id, billing_status, wallet_id, finalized_at, created_at, updated_at
|
||||
) VALUES
|
||||
(?, 'settled', ?, 4100000000, 4100000000, 4100000000),
|
||||
(?, 'settled', ?, 4100000100, 4100000100, 4100000100),
|
||||
(?, 'settled', ?, 4100000150, 4100000150, 4100000150),
|
||||
(?, 'settled', ?, 4100000200, 4100000200, 4100000200)
|
||||
(?, 'settled', ?, ?, ?, ?),
|
||||
(?, 'settled', ?, ?, ?, ?),
|
||||
(?, 'settled', ?, ?, ?, ?),
|
||||
(?, 'settled', ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&request_one)
|
||||
.bind(&wallet_id)
|
||||
.bind(first_finalized_at)
|
||||
.bind(first_finalized_at)
|
||||
.bind(first_finalized_at)
|
||||
.bind(&request_two)
|
||||
.bind(&wallet_id)
|
||||
.bind(last_finalized_at)
|
||||
.bind(last_finalized_at)
|
||||
.bind(last_finalized_at)
|
||||
.bind(&request_zero)
|
||||
.bind(&wallet_id)
|
||||
.bind(zero_finalized_at)
|
||||
.bind(zero_finalized_at)
|
||||
.bind(zero_finalized_at)
|
||||
.bind(&request_outside)
|
||||
.bind(&wallet_id)
|
||||
.bind(outside_finalized_at)
|
||||
.bind(outside_finalized_at)
|
||||
.bind(outside_finalized_at)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("settlement snapshots should seed");
|
||||
@@ -355,12 +391,15 @@ INSERT INTO wallet_daily_usage_ledgers (
|
||||
id, wallet_id, billing_date, billing_timezone, total_cost_usd,
|
||||
total_requests, input_tokens, output_tokens, cache_creation_tokens,
|
||||
cache_read_tokens, aggregated_at, created_at, updated_at
|
||||
) VALUES (?, ?, '2026-05-03', ?, 7.0, 3, 1, 1, 0, 0, 4099999999, 4099999999, 4099999999)
|
||||
) VALUES (?, ?, '2026-05-03', ?, 7.0, 3, 1, 1, 0, 0, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&stale_ledger_id)
|
||||
.bind(&stale_wallet_id)
|
||||
.bind(&timezone)
|
||||
.bind(seed_created_at)
|
||||
.bind(seed_created_at)
|
||||
.bind(seed_created_at)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("stale ledger should seed");
|
||||
@@ -369,9 +408,9 @@ INSERT INTO wallet_daily_usage_ledgers (
|
||||
.aggregate_wallet_daily_usage(&WalletDailyUsageAggregationInput {
|
||||
billing_date: "2026-05-03".to_string(),
|
||||
billing_timezone: timezone.clone(),
|
||||
window_start_unix_secs: 4_100_000_000,
|
||||
window_end_unix_secs: 4_100_000_200,
|
||||
aggregated_at_unix_secs: 4_100_000_300,
|
||||
window_start_unix_secs: window_start as u64,
|
||||
window_end_unix_secs: window_end as u64,
|
||||
aggregated_at_unix_secs: aggregated_at as u64,
|
||||
})
|
||||
.await
|
||||
.expect("wallet daily usage aggregation should run");
|
||||
@@ -425,9 +464,9 @@ WHERE wallet_id = ?
|
||||
assert_eq!(ledger.4, 27);
|
||||
assert_eq!(ledger.5, 4);
|
||||
assert_eq!(ledger.6, 6);
|
||||
assert_eq!(ledger.7, Some(4_100_000_000));
|
||||
assert_eq!(ledger.8, Some(4_100_000_100));
|
||||
assert_eq!(ledger.9, 4_100_000_300);
|
||||
assert_eq!(ledger.7, Some(first_finalized_at));
|
||||
assert_eq!(ledger.8, Some(last_finalized_at));
|
||||
assert_eq!(ledger.9, aggregated_at);
|
||||
|
||||
let stale_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM wallet_daily_usage_ledgers WHERE id = ?")
|
||||
@@ -463,6 +502,18 @@ WHERE wallet_id = ?
|
||||
.await
|
||||
.expect("mysql migrations should run");
|
||||
|
||||
for sql in [
|
||||
"DELETE FROM stats_daily WHERE `date` = 0",
|
||||
"DELETE FROM stats_hourly WHERE hour_utc = 3600",
|
||||
"DELETE FROM usage_settlement_snapshots WHERE request_id LIKE 'request-daily-%' OR request_id LIKE 'stats-%'",
|
||||
"DELETE FROM `usage` WHERE request_id LIKE 'request-%' OR request_id LIKE 'export-request-%' OR request_id LIKE 'stats-%'",
|
||||
] {
|
||||
sqlx::query(sql)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("stats smoke cleanup should run");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO `usage` (
|
||||
|
||||
@@ -56,7 +56,7 @@ async fn next_mysql_stats_hourly_bucket(
|
||||
}
|
||||
let next_bucket: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT MIN(FLOOR(created_at_unix_ms / 3600000) * 3600)
|
||||
SELECT CAST(MIN(FLOOR(created_at_unix_ms / 3600000) * 3600) AS SIGNED)
|
||||
FROM `usage`
|
||||
WHERE created_at_unix_ms >= ?
|
||||
AND created_at_unix_ms < ?
|
||||
@@ -88,7 +88,7 @@ async fn next_mysql_stats_daily_bucket(
|
||||
}
|
||||
let next_bucket: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT MIN(FLOOR(created_at_unix_ms / 86400000) * 86400)
|
||||
SELECT CAST(MIN(FLOOR(created_at_unix_ms / 86400000) * 86400) AS SIGNED)
|
||||
FROM `usage`
|
||||
WHERE created_at_unix_ms >= ?
|
||||
AND created_at_unix_ms < ?
|
||||
@@ -106,19 +106,19 @@ WHERE created_at_unix_ms >= ?
|
||||
|
||||
const MYSQL_STATS_AGGREGATE_SQL: &str = r#"
|
||||
SELECT
|
||||
COUNT(*) AS total_requests,
|
||||
COALESCE(SUM(CASE
|
||||
CAST(COUNT(*) AS SIGNED) AS total_requests,
|
||||
CAST(COALESCE(SUM(CASE
|
||||
WHEN status = 'failed'
|
||||
OR status_code >= 400
|
||||
OR (error_category IS NOT NULL AND error_category <> '')
|
||||
THEN 1 ELSE 0 END), 0) AS error_requests,
|
||||
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
||||
COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_tokens,
|
||||
COALESCE(SUM(total_cost_usd), 0.0) AS total_cost,
|
||||
COALESCE(SUM(actual_total_cost_usd), 0.0) AS actual_total_cost,
|
||||
COALESCE(AVG(response_time_ms), 0.0) AS avg_response_time_ms
|
||||
THEN 1 ELSE 0 END), 0) AS SIGNED) AS error_requests,
|
||||
CAST(COALESCE(SUM(input_tokens), 0) AS SIGNED) AS input_tokens,
|
||||
CAST(COALESCE(SUM(output_tokens), 0) AS SIGNED) AS output_tokens,
|
||||
CAST(COALESCE(SUM(cache_creation_input_tokens), 0) AS SIGNED) AS cache_creation_tokens,
|
||||
CAST(COALESCE(SUM(cache_read_input_tokens), 0) AS SIGNED) AS cache_read_tokens,
|
||||
CAST(COALESCE(SUM(total_cost_usd), 0.0) AS DOUBLE) AS total_cost,
|
||||
CAST(COALESCE(SUM(actual_total_cost_usd), 0.0) AS DOUBLE) AS actual_total_cost,
|
||||
CAST(COALESCE(AVG(response_time_ms), 0.0) AS DOUBLE) AS avg_response_time_ms
|
||||
FROM `usage`
|
||||
WHERE created_at_unix_ms >= ?
|
||||
AND created_at_unix_ms < ?
|
||||
|
||||
@@ -95,12 +95,12 @@ WHERE ledgers.billing_date = $1
|
||||
const MYSQL_SELECT_WALLET_DAILY_USAGE_AGGREGATES_SQL: &str = r#"
|
||||
SELECT
|
||||
usage_settlement_snapshots.wallet_id AS wallet_id,
|
||||
COUNT(*) AS total_requests,
|
||||
COALESCE(SUM(`usage`.total_cost_usd), 0) AS total_cost_usd,
|
||||
COALESCE(SUM(`usage`.input_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(`usage`.output_tokens), 0) AS output_tokens,
|
||||
COALESCE(SUM(`usage`.cache_creation_input_tokens), 0) AS cache_creation_tokens,
|
||||
COALESCE(SUM(`usage`.cache_read_input_tokens), 0) AS cache_read_tokens,
|
||||
CAST(COUNT(*) AS SIGNED) AS total_requests,
|
||||
CAST(COALESCE(SUM(`usage`.total_cost_usd), 0) AS DOUBLE) AS total_cost_usd,
|
||||
CAST(COALESCE(SUM(`usage`.input_tokens), 0) AS SIGNED) AS input_tokens,
|
||||
CAST(COALESCE(SUM(`usage`.output_tokens), 0) AS SIGNED) AS output_tokens,
|
||||
CAST(COALESCE(SUM(`usage`.cache_creation_input_tokens), 0) AS SIGNED) AS cache_creation_tokens,
|
||||
CAST(COALESCE(SUM(`usage`.cache_read_input_tokens), 0) AS SIGNED) AS cache_read_tokens,
|
||||
MIN(COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at)) AS first_finalized_at,
|
||||
MAX(COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at)) AS last_finalized_at
|
||||
FROM `usage`
|
||||
|
||||
@@ -7,7 +7,7 @@ use tracing::info;
|
||||
// Generated by build.rs from schema/bootstrap/postgres.
|
||||
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260505130000;
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260506000000;
|
||||
|
||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
|
||||
@@ -1826,6 +1826,7 @@ fn mysql_value_to_json(row: &sqlx::mysql::MySqlRow, index: usize) -> Result<Valu
|
||||
}
|
||||
|
||||
match raw.type_info().name().to_ascii_uppercase().as_str() {
|
||||
"BOOL" | "BOOLEAN" => Ok(Value::Bool(row.try_get::<bool, _>(index).map_sql_err()?)),
|
||||
"TINYINT" | "TINY" | "SMALLINT" | "SHORT" | "MEDIUMINT" | "INT24" | "INT" | "INTEGER"
|
||||
| "LONG" | "BIGINT" | "LONGLONG" | "YEAR" => {
|
||||
Ok(Value::from(row.try_get::<i64, _>(index).map_sql_err()?))
|
||||
@@ -1869,8 +1870,8 @@ mod tests {
|
||||
export_postgres_core_jsonl, export_sqlite_core_jsonl, import_mysql_jsonl,
|
||||
import_postgres_jsonl, import_sqlite_jsonl, mysql_core_export_domains,
|
||||
normalize_postgres_import_payload, postgres_core_export_domains,
|
||||
sqlite_core_export_domains, DataExportManifest, DataExportRecord, ExportDomain, ExportRow,
|
||||
PostgresImportColumn,
|
||||
sqlite_core_export_domains, DataExportManifest, DataExportRecord, DataImportPlan,
|
||||
ExportDomain, ExportRow, PostgresImportColumn,
|
||||
};
|
||||
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
use crate::lifecycle::migrate::{
|
||||
@@ -2266,7 +2267,10 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
|
||||
let endpoint_id = format!("export-endpoint-{suffix}");
|
||||
let global_model_id = format!("export-global-model-{suffix}");
|
||||
let model_id = format!("export-model-{suffix}");
|
||||
let billing_rule_id = format!("export-billing-rule-{suffix}");
|
||||
let collector_id = format!("export-collector-{suffix}");
|
||||
let config_id = format!("export-config-{suffix}");
|
||||
let config_key = format!("export.config.{suffix}");
|
||||
let wallet_id = format!("export-wallet-{suffix}");
|
||||
let request_id = format!("export-request-{suffix}");
|
||||
|
||||
@@ -2332,22 +2336,24 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
|
||||
sqlx::query(
|
||||
"INSERT INTO billing_rules (id, global_model_id, name, task_type, expression, variables, dimension_mappings, is_enabled, created_at, updated_at) VALUES ($1, $2, 'Rule One', 'chat', 'input_tokens * 0.01', '{}', '{\"input\":\"input_tokens\"}', TRUE, to_timestamp(1), to_timestamp(2))",
|
||||
)
|
||||
.bind("billing-rule-1")
|
||||
.bind(&billing_rule_id)
|
||||
.bind(&global_model_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("billing rule should seed");
|
||||
sqlx::query(
|
||||
"INSERT INTO dimension_collectors (id, api_format, task_type, dimension_name, source_type, value_type, transform_expression, priority, is_enabled, created_at, updated_at) VALUES ($1, 'openai', 'chat', 'input_tokens', 'computed', 'float', 'usage.input_tokens', 10, TRUE, to_timestamp(1), to_timestamp(2))",
|
||||
"INSERT INTO dimension_collectors (id, api_format, task_type, dimension_name, source_type, value_type, transform_expression, priority, is_enabled, created_at, updated_at) VALUES ($1, 'openai', 'chat', $2, 'computed', 'float', 'usage.input_tokens', 10, TRUE, to_timestamp(1), to_timestamp(2))",
|
||||
)
|
||||
.bind("collector-1")
|
||||
.bind(&collector_id)
|
||||
.bind(format!("input_tokens_{suffix}"))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("dimension collector should seed");
|
||||
sqlx::query(
|
||||
"INSERT INTO system_configs (id, key, value, created_at, updated_at) VALUES ($1, 'billing.enabled', 'true', to_timestamp(1), to_timestamp(2))",
|
||||
"INSERT INTO system_configs (id, key, value, created_at, updated_at) VALUES ($1, $2, 'true', to_timestamp(1), to_timestamp(2))",
|
||||
)
|
||||
.bind(&config_id)
|
||||
.bind(&config_key)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("system config should seed");
|
||||
@@ -2417,7 +2423,7 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
|
||||
let imported = import_sqlite_jsonl(&target_pool, &encoded)
|
||||
.await
|
||||
.expect("sqlite import should load postgres exported rows");
|
||||
assert_eq!(imported, 12);
|
||||
assert_eq!(imported, import_plan_row_count(&import_plan));
|
||||
|
||||
let imported_api_key =
|
||||
sqlx::query_as::<_, (String,)>("SELECT key_encrypted FROM api_keys WHERE id = $1")
|
||||
@@ -2602,10 +2608,21 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
|
||||
}
|
||||
|
||||
fn unique_suffix() -> String {
|
||||
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("{:013x}", nanos & 0x1fff_ffff_fffff)
|
||||
.as_nanos() as u64;
|
||||
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
format!("{:016x}", nanos ^ counter.rotate_left(17))
|
||||
}
|
||||
|
||||
fn import_plan_row_count(plan: &DataImportPlan) -> usize {
|
||||
plan.manifest
|
||||
.domains
|
||||
.iter()
|
||||
.map(|domain| plan.rows(*domain).len())
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
|
||||
20260502000000,
|
||||
20260505000000,
|
||||
20260505130000,
|
||||
20260506000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1010,6 +1011,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
||||
20260502000000,
|
||||
20260505000000,
|
||||
20260505130000,
|
||||
20260506000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,9 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
|
||||
"openai:responses" | "openai:responses:compact" | "openai:image"
|
||||
)
|
||||
}
|
||||
"chatgpt_web" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "openai:image"
|
||||
}
|
||||
"claude_code" => auth_type == "oauth" && api_format == "claude:messages",
|
||||
"kiro" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "claude:messages"
|
||||
@@ -324,6 +327,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_chatgpt_web_oauth_and_bearer_for_openai_image_only() {
|
||||
let mut oauth = sample_row("chatgpt-web-oauth", "openai:image", "gpt-image-2", 10);
|
||||
oauth.provider_type = "chatgpt_web".to_string();
|
||||
oauth.key_auth_type = "oauth".to_string();
|
||||
let mut bearer = sample_row("chatgpt-web-bearer", "openai:image", "gpt-image-2", 20);
|
||||
bearer.provider_type = "chatgpt_web".to_string();
|
||||
bearer.key_auth_type = "bearer".to_string();
|
||||
let mut api_key = sample_row("chatgpt-web-api-key", "openai:image", "gpt-image-2", 30);
|
||||
api_key.provider_type = "chatgpt_web".to_string();
|
||||
api_key.key_auth_type = "api_key".to_string();
|
||||
let mut responses = sample_row("chatgpt-web-responses", "openai:responses", "gpt-5", 40);
|
||||
responses.provider_type = "chatgpt_web".to_string();
|
||||
responses.key_auth_type = "oauth".to_string();
|
||||
|
||||
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
oauth, bearer, api_key, responses,
|
||||
]);
|
||||
|
||||
let rows = repository
|
||||
.list_for_exact_api_format_and_requested_model("openai:image", "gpt-image-2")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.map(|row| row.provider_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["chatgpt-web-oauth", "chatgpt-web-bearer"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filters_by_exact_api_format_only() {
|
||||
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
|
||||
@@ -289,6 +289,9 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"openai:responses" | "openai:responses:compact" | "openai:image"
|
||||
)
|
||||
}
|
||||
"chatgpt_web" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "openai:image"
|
||||
}
|
||||
"claude_code" => auth_type == "oauth" && api_format == "claude:messages",
|
||||
"kiro" => {
|
||||
api_format == "claude:messages"
|
||||
|
||||
@@ -80,6 +80,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($3) IN ('openai:responses', 'openai:responses:compact', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')
|
||||
AND LOWER($3) = 'openai:image'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'claude_code'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
@@ -117,6 +122,7 @@ WHERE p.is_active = TRUE
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) NOT IN (
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
@@ -257,6 +263,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($4) IN ('openai:responses', 'openai:responses:compact', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')
|
||||
AND LOWER($4) = 'openai:image'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'claude_code'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
@@ -294,6 +305,7 @@ WHERE p.is_active = TRUE
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) NOT IN (
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
@@ -433,6 +445,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($6) IN ('openai:responses', 'openai:responses:compact', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')
|
||||
AND LOWER($6) = 'openai:image'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'claude_code'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
@@ -470,6 +487,7 @@ WHERE p.is_active = TRUE
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) NOT IN (
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
@@ -1007,6 +1025,8 @@ mod tests {
|
||||
use super::{
|
||||
parse_provider_model_mappings, parse_string_list, requested_model_selection_page_sql,
|
||||
requested_model_selection_sql, SqlxMinimalCandidateSelectionReadRepository,
|
||||
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL, LIST_FOR_EXACT_API_FORMAT_SQL,
|
||||
LIST_POOL_KEYS_FOR_GROUP_SQL,
|
||||
};
|
||||
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
use crate::repository::candidate_selection::StoredProviderModelMapping;
|
||||
@@ -1042,6 +1062,21 @@ mod tests {
|
||||
assert!(!sql.contains("AND gm.name = $2\n AND"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_selection_sql_allows_chatgpt_web_image_auth() {
|
||||
let requested_model_sql = requested_model_selection_sql();
|
||||
for sql in [
|
||||
LIST_FOR_EXACT_API_FORMAT_SQL,
|
||||
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL,
|
||||
LIST_POOL_KEYS_FOR_GROUP_SQL,
|
||||
requested_model_sql.as_str(),
|
||||
] {
|
||||
assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'"));
|
||||
assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')"));
|
||||
assert!(sql.contains("'chatgpt_web',"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_model_selection_page_sql_adds_limit_and_offset() {
|
||||
let sql = requested_model_selection_page_sql();
|
||||
|
||||
@@ -289,6 +289,9 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"openai:responses" | "openai:responses:compact" | "openai:image"
|
||||
)
|
||||
}
|
||||
"chatgpt_web" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "openai:image"
|
||||
}
|
||||
"claude_code" => auth_type == "oauth" && api_format == "claude:messages",
|
||||
"kiro" => {
|
||||
api_format == "claude:messages"
|
||||
@@ -666,6 +669,25 @@ mod tests {
|
||||
.expect("pool keys should load");
|
||||
assert_eq!(pool_keys.len(), 1);
|
||||
assert_eq!(pool_keys[0].key_id, "key-2");
|
||||
|
||||
let image_rows = repository
|
||||
.list_for_exact_api_format_and_requested_model_page(
|
||||
&StoredRequestedModelCandidateRowsQuery {
|
||||
api_format: "openai:image".to_string(),
|
||||
requested_model_name: "gpt-image-2".to_string(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("chatgpt web image rows should load");
|
||||
assert_eq!(
|
||||
image_rows
|
||||
.iter()
|
||||
.map(|row| row.key_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["key-chatgpt-web-oauth", "key-chatgpt-web-bearer"]
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_candidate_selection(pool: &sqlx::SqlitePool) {
|
||||
@@ -688,10 +710,33 @@ VALUES
|
||||
('key-1', 'provider-1', 'Key One', 'api_key', '["openai:chat"]', 10, 1, 1, 1),
|
||||
('key-2', 'provider-1', 'Key Two', 'api_key', '["openai:chat"]', 20, 1, 1, 1);
|
||||
|
||||
INSERT INTO providers (
|
||||
id, name, provider_type, provider_priority, is_active, created_at, updated_at
|
||||
)
|
||||
VALUES ('provider-chatgpt-web', 'ChatGPT Web', 'chatgpt_web', 20, 1, 1, 1);
|
||||
|
||||
INSERT INTO provider_endpoints (
|
||||
id, provider_id, name, base_url, api_format, is_active, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'endpoint-chatgpt-web', 'provider-chatgpt-web', 'ChatGPT Web Image',
|
||||
'https://chatgpt.com', 'openai:image', 1, 1, 1
|
||||
);
|
||||
|
||||
INSERT INTO provider_api_keys (
|
||||
id, provider_id, name, auth_type, api_formats, internal_priority, is_active, created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
('key-chatgpt-web-oauth', 'provider-chatgpt-web', 'OAuth', 'oauth', '["openai:image"]', 10, 1, 1, 1),
|
||||
('key-chatgpt-web-bearer', 'provider-chatgpt-web', 'Bearer', 'bearer', '["openai:image"]', 20, 1, 1, 1),
|
||||
('key-chatgpt-web-api-key', 'provider-chatgpt-web', 'API Key', 'api_key', '["openai:image"]', 30, 1, 1, 1);
|
||||
|
||||
INSERT INTO global_models (
|
||||
id, name, config, is_active, created_at, updated_at
|
||||
)
|
||||
VALUES ('global-1', 'gpt-5', '{"model_mappings":["alias-global"],"streaming":true}', 1, 1, 1);
|
||||
VALUES
|
||||
('global-1', 'gpt-5', '{"model_mappings":["alias-global"],"streaming":true}', 1, 1, 1),
|
||||
('global-image-1', 'gpt-image-2', NULL, 1, 1, 1);
|
||||
|
||||
INSERT INTO models (
|
||||
id, provider_id, global_model_id, provider_model_name, provider_model_mappings,
|
||||
@@ -701,6 +746,10 @@ VALUES (
|
||||
'model-1', 'provider-1', 'global-1', 'provider-model',
|
||||
'[{"name":"alias-provider","api_formats":["openai:chat"],"priority":1}]',
|
||||
1, 1, 1, 1, 1
|
||||
),
|
||||
(
|
||||
'model-chatgpt-web-image', 'provider-chatgpt-web', 'global-image-1', 'gpt-image-2',
|
||||
NULL, 1, 1, 1, 1, 1
|
||||
);
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -304,8 +304,8 @@ SET total_requests = 0,
|
||||
SELECT
|
||||
api_key_id,
|
||||
COUNT(*) AS total_requests,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(total_cost_usd), 0) AS total_cost_usd,
|
||||
CAST(COALESCE(SUM(total_tokens), 0) AS SIGNED) AS total_tokens,
|
||||
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE) AS total_cost_usd,
|
||||
MAX(updated_at_unix_secs) AS last_used_at
|
||||
FROM `usage`
|
||||
WHERE api_key_id IS NOT NULL AND api_key_id <> ''
|
||||
|
||||
@@ -3483,8 +3483,13 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("admin wallets should list");
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].total_adjusted, 3.0);
|
||||
let wallet_item = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.id == "wallet-1")
|
||||
.expect("seeded wallet should be listed");
|
||||
assert!(page.total >= 1);
|
||||
assert_eq!(wallet_item.total_adjusted, 3.0);
|
||||
|
||||
let orders = repository
|
||||
.list_admin_payment_orders(&AdminPaymentOrderListQuery {
|
||||
|
||||
@@ -51,6 +51,18 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
|
||||
use_pkce: true,
|
||||
uses_json_payload: false,
|
||||
},
|
||||
GenericProviderOAuthTemplate {
|
||||
provider_type: "chatgpt_web",
|
||||
display_name: "ChatGPT Web",
|
||||
authorize_url: "https://auth.openai.com/oauth/authorize",
|
||||
token_url: "https://auth.openai.com/oauth/token",
|
||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
client_secret: "",
|
||||
scopes: &["openid", "email", "profile", "offline_access"],
|
||||
redirect_uri: "http://localhost:1455/auth/callback",
|
||||
use_pkce: true,
|
||||
uses_json_payload: false,
|
||||
},
|
||||
GenericProviderOAuthTemplate {
|
||||
provider_type: "gemini_cli",
|
||||
display_name: "GeminiCli",
|
||||
|
||||
@@ -26,7 +26,7 @@ impl ProviderOAuthService {
|
||||
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()));
|
||||
for provider_type in ["claude_code", "gemini_cli"] {
|
||||
for provider_type in ["claude_code", "chatgpt_web", "gemini_cli"] {
|
||||
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
|
||||
service = service.with_adapter(Arc::new(adapter));
|
||||
}
|
||||
@@ -121,7 +121,14 @@ mod tests {
|
||||
fn builtin_provider_service_registers_supported_provider_types() {
|
||||
let service = ProviderOAuthService::with_builtin_adapters();
|
||||
|
||||
for provider_type in ["claude_code", "codex", "gemini_cli", "antigravity", "kiro"] {
|
||||
for provider_type in [
|
||||
"claude_code",
|
||||
"codex",
|
||||
"chatgpt_web",
|
||||
"gemini_cli",
|
||||
"antigravity",
|
||||
"kiro",
|
||||
] {
|
||||
assert!(
|
||||
service.adapter(provider_type).is_ok(),
|
||||
"{provider_type} adapter should be registered"
|
||||
|
||||
@@ -22,7 +22,17 @@ pub fn openai_image_transport_unsupported_reason(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
api_format: &str,
|
||||
) -> Option<&'static str> {
|
||||
local_standard_transport_unsupported_reason_with_network(transport, api_format)
|
||||
let reason = local_standard_transport_unsupported_reason_with_network(transport, api_format);
|
||||
if reason == Some("transport_provider_type_unsupported")
|
||||
&& transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("chatgpt_web")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
reason
|
||||
}
|
||||
|
||||
pub fn resolve_openai_image_auth(
|
||||
@@ -68,7 +78,7 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
build_openai_image_headers, build_openai_image_upstream_url,
|
||||
ProviderOpenAiImageHeadersInput,
|
||||
openai_image_transport_unsupported_reason, ProviderOpenAiImageHeadersInput,
|
||||
};
|
||||
use crate::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
@@ -137,6 +147,17 @@ mod tests {
|
||||
assert_eq!(url, "https://api.openai.com/v1/responses?trace=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_is_supported_by_dedicated_openai_image_transport_policy() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "chatgpt_web".to_string();
|
||||
|
||||
assert_eq!(
|
||||
openai_image_transport_unsupported_reason(&transport, "openai:image"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_json_eventstream_headers_and_applies_rules() {
|
||||
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
|
||||
@@ -95,6 +95,18 @@ const CODEX_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTempla
|
||||
],
|
||||
};
|
||||
|
||||
const CHATGPT_WEB_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "chatgpt_web",
|
||||
version: 1,
|
||||
base_url: "https://chatgpt.com",
|
||||
endpoints: &[FixedProviderEndpointTemplate {
|
||||
item_key: "openai:image",
|
||||
api_format: "openai:image",
|
||||
custom_path: None,
|
||||
config_defaults: FORCE_STREAM_ENDPOINT_CONFIG_DEFAULTS,
|
||||
}],
|
||||
};
|
||||
|
||||
const KIRO_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "kiro",
|
||||
version: 1,
|
||||
@@ -154,7 +166,13 @@ const ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProvider
|
||||
pub fn provider_type_is_fixed(provider_type: &str) -> bool {
|
||||
matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"claude_code" | "kiro" | "codex" | "gemini_cli" | "antigravity" | "vertex_ai"
|
||||
"claude_code"
|
||||
| "kiro"
|
||||
| "codex"
|
||||
| "chatgpt_web"
|
||||
| "gemini_cli"
|
||||
| "antigravity"
|
||||
| "vertex_ai"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,6 +185,7 @@ pub fn fixed_provider_key_inherits_api_formats(
|
||||
let auth_type = auth_type.trim().to_ascii_lowercase();
|
||||
provider_type_is_fixed(&provider_type)
|
||||
&& (auth_type == "oauth"
|
||||
|| provider_type == "chatgpt_web" && auth_type == "bearer"
|
||||
|| provider_type == "kiro"
|
||||
&& auth_type == "bearer"
|
||||
&& decrypted_auth_config
|
||||
@@ -177,7 +196,7 @@ pub fn fixed_provider_key_inherits_api_formats(
|
||||
pub fn provider_type_enables_format_conversion_by_default(provider_type: &str) -> bool {
|
||||
matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"claude_code" | "kiro" | "codex" | "antigravity" | "vertex_ai"
|
||||
"claude_code" | "kiro" | "codex" | "chatgpt_web" | "antigravity" | "vertex_ai"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,6 +204,7 @@ pub fn fixed_provider_template(provider_type: &str) -> Option<&'static FixedProv
|
||||
match provider_type.trim().to_ascii_lowercase().as_str() {
|
||||
"claude_code" => Some(&CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE),
|
||||
"codex" => Some(&CODEX_FIXED_PROVIDER_TEMPLATE),
|
||||
"chatgpt_web" => Some(&CHATGPT_WEB_FIXED_PROVIDER_TEMPLATE),
|
||||
"kiro" => Some(&KIRO_FIXED_PROVIDER_TEMPLATE),
|
||||
"gemini_cli" => Some(&GEMINI_CLI_FIXED_PROVIDER_TEMPLATE),
|
||||
"vertex_ai" => Some(&VERTEX_AI_FIXED_PROVIDER_TEMPLATE),
|
||||
@@ -207,21 +227,27 @@ pub fn fixed_provider_endpoint_template_by_api_format(
|
||||
pub fn provider_type_supports_model_fetch(provider_type: &str) -> bool {
|
||||
!matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"vertex_ai" | "antigravity" | "codex" | "kiro" | "claude_code"
|
||||
"vertex_ai" | "antigravity" | "codex" | "chatgpt_web" | "kiro" | "claude_code"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn provider_type_supports_local_openai_chat_transport(provider_type: &str) -> bool {
|
||||
!matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"antigravity" | "claude_code" | "codex" | "gemini_cli" | "kiro" | "vertex_ai"
|
||||
"antigravity"
|
||||
| "claude_code"
|
||||
| "codex"
|
||||
| "chatgpt_web"
|
||||
| "gemini_cli"
|
||||
| "kiro"
|
||||
| "vertex_ai"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn provider_type_supports_local_same_format_transport(provider_type: &str) -> bool {
|
||||
!matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"antigravity" | "claude_code" | "kiro" | "vertex_ai"
|
||||
"antigravity" | "chatgpt_web" | "claude_code" | "kiro" | "vertex_ai"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -276,6 +302,17 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
|
||||
redirect_uri: "http://localhost:1455/auth/callback",
|
||||
use_pkce: true,
|
||||
}),
|
||||
"chatgpt_web" => Some(ProviderOAuthTemplate {
|
||||
provider_type: "chatgpt_web",
|
||||
display_name: "ChatGPT Web",
|
||||
authorize_url: "https://auth.openai.com/oauth/authorize",
|
||||
token_url: "https://auth.openai.com/oauth/token",
|
||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
client_secret: "",
|
||||
scopes: &["openid", "email", "profile", "offline_access"],
|
||||
redirect_uri: "http://localhost:1455/auth/callback",
|
||||
use_pkce: true,
|
||||
}),
|
||||
"gemini_cli" => Some(ProviderOAuthTemplate {
|
||||
provider_type: "gemini_cli",
|
||||
display_name: "GeminiCli",
|
||||
@@ -312,15 +349,20 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
|
||||
}
|
||||
}
|
||||
|
||||
pub const ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES: &[&str] =
|
||||
&["claude_code", "codex", "gemini_cli", "antigravity"];
|
||||
pub const ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES: &[&str] = &[
|
||||
"claude_code",
|
||||
"codex",
|
||||
"chatgpt_web",
|
||||
"gemini_cli",
|
||||
"antigravity",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
fixed_provider_endpoint_template_by_api_format, fixed_provider_key_inherits_api_formats,
|
||||
fixed_provider_template, provider_type_supports_local_embedding_transport,
|
||||
FixedProviderEndpointConfigValue,
|
||||
provider_type_supports_local_same_format_transport, FixedProviderEndpointConfigValue,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -357,11 +399,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_fixed_provider_template_only_exposes_openai_image() {
|
||||
let template =
|
||||
fixed_provider_template("chatgpt_web").expect("chatgpt_web template should exist");
|
||||
assert_eq!(template.base_url, "https://chatgpt.com");
|
||||
assert_eq!(template.version, 1);
|
||||
assert_eq!(
|
||||
template
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|item| item.api_format)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["openai:image"]
|
||||
);
|
||||
|
||||
let image_template =
|
||||
fixed_provider_endpoint_template_by_api_format("chatgpt_web", "openai:image")
|
||||
.expect("chatgpt_web image endpoint should exist");
|
||||
assert_eq!(
|
||||
image_template
|
||||
.config_defaults
|
||||
.iter()
|
||||
.map(|item| (item.key, item.value))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(
|
||||
"upstream_stream_policy",
|
||||
FixedProviderEndpointConfigValue::String("force_stream")
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_provider_key_inheritance_keeps_oauth_and_kiro_configured_bearer_keys_open() {
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
"codex", "oauth", None
|
||||
));
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
"chatgpt_web",
|
||||
"oauth",
|
||||
None
|
||||
));
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
"kiro",
|
||||
"bearer",
|
||||
@@ -375,6 +453,13 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_does_not_use_generic_same_format_transport() {
|
||||
assert!(!provider_type_supports_local_same_format_transport(
|
||||
"chatgpt_web"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_type_supports_only_matching_embedding_formats() {
|
||||
for (provider_type, api_format) in [
|
||||
|
||||
Reference in New Issue
Block a user