mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Merge commit 'refs/pull/481/head' of github-fawney19:fawney19/Aether
# Conflicts: # apps/aether-gateway/src/ai_serving/api.rs # apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs # apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs # apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs
This commit is contained in:
@@ -180,9 +180,10 @@ pub use crate::formats::{
|
||||
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, ChatGptWebImageRequestError,
|
||||
NormalizedOpenAiImageRequest, OpenAiImageOperation, OpenAiImageResponseFormat,
|
||||
normalize_openai_image_request, normalize_openai_image_request_with_options,
|
||||
openai_image_operation_from_path, resolve_requested_openai_image_model_for_request,
|
||||
ChatGptWebImageRequestError, NormalizedOpenAiImageRequest, OpenAiImageNormalizeOptions,
|
||||
OpenAiImageOperation, OpenAiImageResponseFormat,
|
||||
},
|
||||
spec::{
|
||||
resolve_stream_spec as resolve_local_image_stream_spec,
|
||||
|
||||
@@ -669,6 +669,12 @@ impl ClaudeClientEmitter {
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ContentPart(part) => self.emit_content_part(part),
|
||||
CanonicalStreamEvent::ImageGenerationCall { item, .. } => {
|
||||
let Some(part) = content_part_from_openai_image_generation_item(&item) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.emit_content_part(part)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
|
||||
@@ -485,6 +485,16 @@ impl GeminiClientEmitter {
|
||||
None,
|
||||
None,
|
||||
),
|
||||
CanonicalStreamEvent::ImageGenerationCall { item, .. } => {
|
||||
let Some(part) = content_part_from_openai_image_generation_item(&item) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.emit_candidate(
|
||||
vec![gemini_part_from_canonical_content_part(part)],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
@@ -50,6 +50,7 @@ pub struct OpenAIResponsesProviderState {
|
||||
tool_calls: BTreeMap<usize, OpenAIResponsesProviderToolState>,
|
||||
tool_results: BTreeMap<usize, OpenAIResponsesProviderToolResultState>,
|
||||
tool_index_by_key: BTreeMap<String, usize>,
|
||||
image_item_keys: BTreeSet<String>,
|
||||
last_tool_index: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -718,6 +719,55 @@ impl OpenAIResponsesProviderState {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_image_generation_item(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
item: &Map<String, Value>,
|
||||
output_index: Option<usize>,
|
||||
final_item: bool,
|
||||
) {
|
||||
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
|
||||
return;
|
||||
}
|
||||
if !final_item
|
||||
&& !item
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("completed"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let has_image_payload = item
|
||||
.get("result")
|
||||
.or_else(|| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if !has_image_payload {
|
||||
return;
|
||||
}
|
||||
let index = output_index.unwrap_or(self.image_item_keys.len());
|
||||
let key = item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("image_generation_call:{index}"));
|
||||
if !self.image_item_keys.insert(key) {
|
||||
return;
|
||||
}
|
||||
self.ensure_started(report_context, out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ImageGenerationCall {
|
||||
index,
|
||||
item: Value::Object(item.clone()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -894,6 +944,15 @@ impl OpenAIResponsesProviderState {
|
||||
"reasoning" => {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
"image_generation_call" => {
|
||||
self.emit_image_generation_item(
|
||||
report_context,
|
||||
&mut out,
|
||||
item,
|
||||
output_index,
|
||||
false,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
||||
}
|
||||
@@ -1107,6 +1166,15 @@ impl OpenAIResponsesProviderState {
|
||||
"reasoning" => {
|
||||
self.emit_reasoning_item(report_context, &mut out, item);
|
||||
}
|
||||
"image_generation_call" => {
|
||||
self.emit_image_generation_item(
|
||||
report_context,
|
||||
&mut out,
|
||||
item,
|
||||
output_index,
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
||||
}
|
||||
@@ -1152,6 +1220,15 @@ impl OpenAIResponsesProviderState {
|
||||
"reasoning" => {
|
||||
self.emit_reasoning_item(report_context, &mut out, item);
|
||||
}
|
||||
"image_generation_call" => {
|
||||
self.emit_image_generation_item(
|
||||
report_context,
|
||||
&mut out,
|
||||
item,
|
||||
Some(output_index),
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
out.push(
|
||||
self.unknown_frame(report_context, Value::Object(item.clone())),
|
||||
@@ -1255,6 +1332,7 @@ pub struct OpenAIResponsesClientEmitter {
|
||||
reasoning_summary_parts: Vec<String>,
|
||||
tool_calls: BTreeMap<usize, OpenAIResponsesClientToolState>,
|
||||
tool_results: BTreeMap<usize, OpenAIResponsesClientToolResultState>,
|
||||
image_generation_items: BTreeMap<usize, Value>,
|
||||
}
|
||||
|
||||
impl OpenAIChatClientEmitter {
|
||||
@@ -1361,6 +1439,26 @@ impl OpenAIChatClientEmitter {
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ImageGenerationCall { item, .. } => {
|
||||
let Some(part) = content_part_from_openai_image_generation_item(&item) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let placeholder = openai_stream_placeholder_for_content_part(&part);
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
placeholder,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
@@ -1653,6 +1751,11 @@ impl OpenAIResponsesClientEmitter {
|
||||
output_index
|
||||
}
|
||||
|
||||
fn ensure_image_generation_output_index(&mut self, index: usize) -> usize {
|
||||
self.next_output_index = self.next_output_index.max(index.saturating_add(1));
|
||||
index
|
||||
}
|
||||
|
||||
fn ensure_reasoning_item_started(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.ensure_reasoning_output_index();
|
||||
@@ -1956,6 +2059,42 @@ impl OpenAIResponsesClientEmitter {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn emit_image_generation_call_item(
|
||||
&mut self,
|
||||
index: usize,
|
||||
item: Value,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.ensure_image_generation_output_index(index);
|
||||
let mut item = item.as_object().cloned().unwrap_or_default();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String("image_generation_call".to_string()),
|
||||
);
|
||||
if !item.contains_key("id") {
|
||||
item.insert(
|
||||
"id".to_string(),
|
||||
Value::String(format!("{}_ig_{}", self.response_id(), output_index)),
|
||||
);
|
||||
}
|
||||
if !item.contains_key("status") {
|
||||
item.insert("status".to_string(), Value::String("completed".to_string()));
|
||||
}
|
||||
let item = Value::Object(item);
|
||||
self.image_generation_items
|
||||
.insert(output_index, item.clone());
|
||||
out.extend(self.encode_response_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"response_id": self.response_id(),
|
||||
"output_index": output_index,
|
||||
"item": item,
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn completed_response(&self, usage: CanonicalUsage) -> Value {
|
||||
let mut ordered_output = Vec::new();
|
||||
let summary = if self.reasoning_summary_parts.is_empty() {
|
||||
@@ -2058,6 +2197,9 @@ impl OpenAIResponsesClientEmitter {
|
||||
ordered_output.push((output_index, Value::Object(item)));
|
||||
}
|
||||
}
|
||||
for (output_index, item) in &self.image_generation_items {
|
||||
ordered_output.push((*output_index, item.clone()));
|
||||
}
|
||||
ordered_output.sort_by_key(|(output_index, _)| *output_index);
|
||||
|
||||
let mut usage_payload = Map::new();
|
||||
@@ -2183,6 +2325,9 @@ impl OpenAIResponsesClientEmitter {
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ImageGenerationCall { index, item } => {
|
||||
self.emit_image_generation_call_item(index, item)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
@@ -2876,6 +3021,134 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_provider_state_preserves_image_generation_calls() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_img_123",
|
||||
"model": "gpt-image-2",
|
||||
"output": [{
|
||||
"id": "ig_123",
|
||||
"type": "image_generation_call",
|
||||
"status": "completed",
|
||||
"output_format": "png",
|
||||
"result": "aGVsbG8="
|
||||
}],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("completed event should parse");
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::ImageGenerationCall {
|
||||
index: 0,
|
||||
ref item,
|
||||
} if item["type"] == json!("image_generation_call")
|
||||
&& item["result"] == json!("aGVsbG8=")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_provider_state_waits_for_final_image_generation_item() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
|
||||
let added_frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"id": "ig_123",
|
||||
"type": "image_generation_call",
|
||||
"status": "generating",
|
||||
"output_format": "png",
|
||||
"result": "early"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("added event should parse");
|
||||
|
||||
assert!(!added_frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::ImageGenerationCall { .. }
|
||||
)));
|
||||
|
||||
let done_frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"id": "ig_123",
|
||||
"type": "image_generation_call",
|
||||
"status": "completed",
|
||||
"output_format": "png",
|
||||
"result": "final"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("done event should parse");
|
||||
|
||||
assert!(done_frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::ImageGenerationCall {
|
||||
index: 0,
|
||||
ref item,
|
||||
} if item["status"] == json!("completed") && item["result"] == json!("final")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_client_emitter_emits_image_generation_call_events() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_img_123".to_string(),
|
||||
model: "gpt-image-2".to_string(),
|
||||
event: CanonicalStreamEvent::ImageGenerationCall {
|
||||
index: 0,
|
||||
item: json!({
|
||||
"id": "ig_123",
|
||||
"type": "image_generation_call",
|
||||
"status": "completed",
|
||||
"output_format": "png",
|
||||
"result": "aGVsbG8="
|
||||
}),
|
||||
},
|
||||
})
|
||||
.expect("image event should encode");
|
||||
bytes.extend(
|
||||
emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_img_123".to_string(),
|
||||
model: "gpt-image-2".to_string(),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some("stop".to_string()),
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
.expect("finish should encode"),
|
||||
);
|
||||
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("event: response.output_item.done\n"));
|
||||
assert!(sse.contains("\"type\":\"image_generation_call\""));
|
||||
assert!(sse.contains("\"result\":\"aGVsbG8=\""));
|
||||
assert!(sse.contains("\"output\":["));
|
||||
assert!(sse.contains("\"id\":\"ig_123\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_client_emitter_emits_function_call_output_events() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
|
||||
@@ -3,15 +3,12 @@ use std::collections::BTreeMap;
|
||||
use base64::Engine as _;
|
||||
use serde_json::{json, Map, Number, Value};
|
||||
|
||||
use crate::formats::openai::responses::codex::{
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
|
||||
};
|
||||
use crate::formats::openai::responses::codex::CODEX_OPENAI_IMAGE_DEFAULT_MODEL;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OpenAiImageOperation {
|
||||
Generate,
|
||||
Edit,
|
||||
Variation,
|
||||
}
|
||||
|
||||
impl OpenAiImageOperation {
|
||||
@@ -19,7 +16,6 @@ impl OpenAiImageOperation {
|
||||
match self {
|
||||
Self::Generate => "generate",
|
||||
Self::Edit => "edit",
|
||||
Self::Variation => "variation",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,9 +43,31 @@ pub struct NormalizedOpenAiImageRequest {
|
||||
prompt: Option<String>,
|
||||
images: Vec<Value>,
|
||||
tool: Map<String, Value>,
|
||||
image_count: Option<u64>,
|
||||
user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OpenAiImageNormalizeOptions {
|
||||
max_generation_count: u64,
|
||||
}
|
||||
|
||||
impl Default for OpenAiImageNormalizeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_generation_count: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAiImageNormalizeOptions {
|
||||
pub fn with_max_generation_count(max_generation_count: u64) -> Self {
|
||||
Self {
|
||||
max_generation_count: max_generation_count.max(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const CHATGPT_WEB_IMAGE_MAX_AREA: u64 = 1_500_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -123,7 +141,6 @@ pub fn build_chatgpt_web_image_request_body(
|
||||
.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."
|
||||
}
|
||||
@@ -244,7 +261,6 @@ pub fn openai_image_operation_from_path(path: &str) -> Option<OpenAiImageOperati
|
||||
match path {
|
||||
"/v1/images/generations" => Some(OpenAiImageOperation::Generate),
|
||||
"/v1/images/edits" => Some(OpenAiImageOperation::Edit),
|
||||
"/v1/images/variations" => Some(OpenAiImageOperation::Variation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -435,7 +451,6 @@ pub fn resolve_requested_openai_image_model_for_request(
|
||||
|
||||
pub fn default_model_for_openai_image_operation(operation: OpenAiImageOperation) -> &'static str {
|
||||
match operation {
|
||||
OpenAiImageOperation::Variation => CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
|
||||
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit => {
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_MODEL
|
||||
}
|
||||
@@ -446,12 +461,26 @@ pub fn normalize_openai_image_request(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> Option<NormalizedOpenAiImageRequest> {
|
||||
normalize_openai_image_request_with_options(
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
OpenAiImageNormalizeOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn normalize_openai_image_request_with_options(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
body_base64: Option<&str>,
|
||||
options: OpenAiImageNormalizeOptions,
|
||||
) -> Option<NormalizedOpenAiImageRequest> {
|
||||
let operation = openai_image_operation_from_path(parts.uri.path())?;
|
||||
if let Some(body_base64) = body_base64 {
|
||||
normalize_openai_image_multipart_request(parts, body_base64, operation)
|
||||
normalize_openai_image_multipart_request(parts, body_base64, operation, options)
|
||||
} else {
|
||||
normalize_openai_image_json_request(body_json, operation)
|
||||
normalize_openai_image_json_request(body_json, operation, options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,12 +515,16 @@ pub fn build_openai_image_provider_request_body(request: &NormalizedOpenAiImageR
|
||||
if let Some(user) = request.user.as_ref() {
|
||||
body.insert("user".to_string(), Value::String(user.clone()));
|
||||
}
|
||||
if let Some(image_count) = request.image_count.filter(|value| *value > 1) {
|
||||
body.insert("n".to_string(), Value::Number(Number::from(image_count)));
|
||||
}
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
fn normalize_openai_image_json_request(
|
||||
body_json: &Value,
|
||||
operation: OpenAiImageOperation,
|
||||
options: OpenAiImageNormalizeOptions,
|
||||
) -> Option<NormalizedOpenAiImageRequest> {
|
||||
let object = body_json.as_object()?;
|
||||
if object
|
||||
@@ -502,10 +535,9 @@ fn normalize_openai_image_json_request(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if object
|
||||
.get("n")
|
||||
.and_then(image_request_count)
|
||||
.is_some_and(|value| value != 1)
|
||||
let image_count = object.get("n").and_then(image_request_count);
|
||||
if image_count
|
||||
.is_some_and(|value| value == 0 || value > max_count_for_operation(operation, options))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -534,11 +566,7 @@ fn normalize_openai_image_json_request(
|
||||
}
|
||||
}
|
||||
let mask = object.get("mask").and_then(normalize_mask_value);
|
||||
if matches!(
|
||||
operation,
|
||||
OpenAiImageOperation::Edit | OpenAiImageOperation::Variation
|
||||
) && images.is_empty()
|
||||
{
|
||||
if matches!(operation, OpenAiImageOperation::Edit) && images.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -550,6 +578,7 @@ fn normalize_openai_image_json_request(
|
||||
prompt,
|
||||
images,
|
||||
tool,
|
||||
image_count,
|
||||
user,
|
||||
summary_json: build_image_request_summary_json(
|
||||
operation,
|
||||
@@ -564,6 +593,7 @@ fn normalize_openai_image_multipart_request(
|
||||
parts: &http::request::Parts,
|
||||
body_base64: &str,
|
||||
operation: OpenAiImageOperation,
|
||||
options: OpenAiImageNormalizeOptions,
|
||||
) -> Option<NormalizedOpenAiImageRequest> {
|
||||
let multipart_fields = parse_multipart_fields_from_base64(parts, body_base64)?;
|
||||
let requested_model = normalize_requested_image_model(
|
||||
@@ -572,9 +602,10 @@ fn normalize_openai_image_multipart_request(
|
||||
if find_multipart_text_field(&multipart_fields, "style").is_some() {
|
||||
return None;
|
||||
}
|
||||
if find_multipart_text_field(&multipart_fields, "n")
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.is_some_and(|value| value != 1)
|
||||
let image_count = find_multipart_text_field(&multipart_fields, "n")
|
||||
.and_then(|value| value.trim().parse::<u64>().ok());
|
||||
if image_count
|
||||
.is_some_and(|value| value == 0 || value > max_count_for_operation(operation, options))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -635,11 +666,7 @@ fn normalize_openai_image_multipart_request(
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(
|
||||
operation,
|
||||
OpenAiImageOperation::Edit | OpenAiImageOperation::Variation
|
||||
) && images.is_empty()
|
||||
{
|
||||
if matches!(operation, OpenAiImageOperation::Edit) && images.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -651,6 +678,7 @@ fn normalize_openai_image_multipart_request(
|
||||
prompt,
|
||||
images,
|
||||
tool,
|
||||
image_count,
|
||||
user,
|
||||
summary_json: build_image_request_summary_json(
|
||||
operation,
|
||||
@@ -677,10 +705,8 @@ fn normalize_prompt(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
match operation {
|
||||
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit => prompt.map(Some),
|
||||
OpenAiImageOperation::Variation => Some(prompt),
|
||||
}
|
||||
let _ = operation;
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
fn normalize_image_response_format(
|
||||
@@ -804,7 +830,7 @@ fn build_tool_options(
|
||||
Value::String(
|
||||
match operation {
|
||||
OpenAiImageOperation::Generate => "generate",
|
||||
OpenAiImageOperation::Edit | OpenAiImageOperation::Variation => "edit",
|
||||
OpenAiImageOperation::Edit => "edit",
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
@@ -892,6 +918,16 @@ fn image_request_count(value: &Value) -> Option<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn max_count_for_operation(
|
||||
operation: OpenAiImageOperation,
|
||||
options: OpenAiImageNormalizeOptions,
|
||||
) -> u64 {
|
||||
match operation {
|
||||
OpenAiImageOperation::Generate => options.max_generation_count.max(1),
|
||||
OpenAiImageOperation::Edit => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_image_value(value: &Value) -> Vec<Value> {
|
||||
match value {
|
||||
Value::Array(values) => values.iter().flat_map(normalize_image_value).collect(),
|
||||
@@ -1101,7 +1137,9 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
|
||||
is_openai_image_stream_request, normalize_openai_image_request, OpenAiImageOperation,
|
||||
is_openai_image_stream_request, normalize_openai_image_request,
|
||||
normalize_openai_image_request_with_options, openai_image_operation_from_path,
|
||||
OpenAiImageNormalizeOptions, OpenAiImageOperation,
|
||||
};
|
||||
use crate::formats::openai::image::spec::{resolve_stream_spec, resolve_sync_spec};
|
||||
use crate::formats::openai::responses::codex::{
|
||||
@@ -1168,7 +1206,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_variation_multipart_request_leaves_defaults_empty_until_codex_adapter() {
|
||||
fn openai_image_variation_path_is_not_supported() {
|
||||
let boundary = "boundary-variation-123";
|
||||
let body = format!(
|
||||
concat!(
|
||||
@@ -1176,9 +1214,6 @@ mod tests {
|
||||
"Content-Disposition: form-data; name=\"image\"; filename=\"image.png\"\r\n",
|
||||
"Content-Type: image/png\r\n\r\n",
|
||||
"hello\r\n",
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"response_format\"\r\n\r\n",
|
||||
"url\r\n",
|
||||
"--{boundary}--\r\n"
|
||||
),
|
||||
boundary = boundary,
|
||||
@@ -1189,40 +1224,8 @@ mod tests {
|
||||
Some(&format!("multipart/form-data; boundary={boundary}")),
|
||||
);
|
||||
|
||||
let request = normalize_openai_image_request(&parts, &json!({}), Some(&body_base64))
|
||||
.expect("variation request should normalize");
|
||||
|
||||
assert_eq!(request.operation, OpenAiImageOperation::Variation);
|
||||
assert!(request.requested_model.is_none());
|
||||
assert_eq!(request.summary_json["response_format"], json!("url"));
|
||||
assert_eq!(
|
||||
request.tool.get("action").and_then(|value| value.as_str()),
|
||||
Some("edit")
|
||||
);
|
||||
assert!(request.tool.get("output_format").is_none());
|
||||
assert_eq!(request.images.len(), 1);
|
||||
|
||||
let mut provider_request_body = build_openai_image_provider_request_body(&request);
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
"codex",
|
||||
"openai:image",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["input"][0]["content"][0]["text"],
|
||||
json!("Create a faithful variation of the provided image.")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["model"],
|
||||
json!(CODEX_OPENAI_IMAGE_INTERNAL_MODEL)
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["tools"][0]["output_format"],
|
||||
json!("png")
|
||||
);
|
||||
assert!(openai_image_operation_from_path("/v1/images/variations").is_none());
|
||||
assert!(normalize_openai_image_request(&parts, &json!({}), Some(&body_base64)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1318,6 +1321,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_generate_json_request_keeps_allowed_multi_image_count() {
|
||||
let parts = request_parts("/v1/images/generations", Some("application/json"));
|
||||
let request = normalize_openai_image_request_with_options(
|
||||
&parts,
|
||||
&json!({
|
||||
"model": "grok-imagine-image",
|
||||
"prompt": "generate image",
|
||||
"n": 4
|
||||
}),
|
||||
None,
|
||||
OpenAiImageNormalizeOptions::with_max_generation_count(4),
|
||||
)
|
||||
.expect("grok generation request should allow n up to four");
|
||||
|
||||
let provider_request_body = build_openai_image_provider_request_body(&request);
|
||||
assert_eq!(provider_request_body["n"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_generate_json_request_rejects_multi_image_count_by_default() {
|
||||
let parts = request_parts("/v1/images/generations", Some("application/json"));
|
||||
assert!(normalize_openai_image_request(
|
||||
&parts,
|
||||
&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "generate image",
|
||||
"n": 2
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_edit_request_rejects_multi_image_count_even_with_generation_override() {
|
||||
let parts = request_parts("/v1/images/edits", Some("application/json"));
|
||||
assert!(normalize_openai_image_request_with_options(
|
||||
&parts,
|
||||
&json!({
|
||||
"model": "grok-imagine-image-edit",
|
||||
"prompt": "edit image",
|
||||
"n": 2,
|
||||
"image": {
|
||||
"b64_json": "aGVsbG8=",
|
||||
"mime_type": "image/png"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
OpenAiImageNormalizeOptions::with_max_generation_count(4),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_generate_request_defaults_codex_image_tool_and_tool_choice() {
|
||||
let parts = request_parts("/v1/images/generations", Some("application/json"));
|
||||
|
||||
@@ -44,6 +44,18 @@ fn is_openai_image_request(provider_api_format: &str) -> bool {
|
||||
.eq_ignore_ascii_case("openai:image")
|
||||
}
|
||||
|
||||
fn codex_openai_responses_body_uses_image_generation_tool(
|
||||
body_object: &serde_json::Map<String, Value>,
|
||||
) -> bool {
|
||||
body_object
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_object)
|
||||
.any(|tool| tool.get("type").and_then(Value::as_str) == Some("image_generation"))
|
||||
}
|
||||
|
||||
fn apply_codex_openai_image_tool_overrides(body_object: &mut serde_json::Map<String, Value>) {
|
||||
let mut tool = body_object
|
||||
.get("tools")
|
||||
@@ -397,7 +409,9 @@ pub fn apply_codex_openai_responses_special_body_edits(
|
||||
{
|
||||
body_object.insert("instructions".to_string(), json!(""));
|
||||
}
|
||||
if is_openai_image_request(provider_api_format) {
|
||||
if is_openai_image_request(provider_api_format)
|
||||
|| codex_openai_responses_body_uses_image_generation_tool(body_object)
|
||||
{
|
||||
body_object.insert(
|
||||
"model".to_string(),
|
||||
json!(CODEX_OPENAI_IMAGE_INTERNAL_MODEL),
|
||||
@@ -712,6 +726,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_image_tool_edits_force_internal_model_and_tool_defaults() {
|
||||
let mut provider_request_body = json!({
|
||||
"model": "gpt-image-2",
|
||||
"input": "generate image",
|
||||
"tools": [{
|
||||
"type": "image_generation"
|
||||
}]
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["model"],
|
||||
json!(CODEX_OPENAI_IMAGE_INTERNAL_MODEL)
|
||||
);
|
||||
assert_eq!(provider_request_body["stream"], json!(true));
|
||||
assert_eq!(
|
||||
provider_request_body["tools"][0]["type"],
|
||||
json!("image_generation")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["tool_choice"]["type"],
|
||||
json!("image_generation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_image_body_edits_preserve_edit_action_without_generate_defaults() {
|
||||
let mut provider_request_body = json!({
|
||||
|
||||
@@ -89,13 +89,37 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
for block in &canonical.content {
|
||||
match block {
|
||||
CanonicalContentBlock::Text { .. }
|
||||
| CanonicalContentBlock::Image { .. }
|
||||
| CanonicalContentBlock::File { .. }
|
||||
| CanonicalContentBlock::Audio { .. } => {
|
||||
if let Some(part) = canonical_content_block_to_openai_responses_part(block) {
|
||||
message_content.push(part);
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
if image_block_is_generation_call(extensions) {
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
output.push(openai_responses_image_generation_call_item(
|
||||
&response_id,
|
||||
output.len(),
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
));
|
||||
} else if let Some(part) = canonical_content_block_to_openai_responses_part(block) {
|
||||
message_content.push(part);
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Thinking {
|
||||
text,
|
||||
encrypted_content,
|
||||
@@ -248,3 +272,56 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
));
|
||||
Value::Object(response)
|
||||
}
|
||||
|
||||
fn image_block_is_generation_call(extensions: &BTreeMap<String, Value>) -> bool {
|
||||
extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE))
|
||||
.and_then(|value| value.get("item_type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "image_generation_call")
|
||||
}
|
||||
|
||||
fn openai_responses_image_generation_call_item(
|
||||
response_id: &str,
|
||||
index: usize,
|
||||
data: &Option<String>,
|
||||
url: &Option<String>,
|
||||
media_type: &Option<String>,
|
||||
) -> Value {
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"id".to_string(),
|
||||
Value::String(format!("{response_id}_ig_{index}")),
|
||||
);
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String("image_generation_call".to_string()),
|
||||
);
|
||||
item.insert("status".to_string(), Value::String("completed".to_string()));
|
||||
item.insert("action".to_string(), Value::String("generate".to_string()));
|
||||
item.insert(
|
||||
"output_format".to_string(),
|
||||
Value::String(openai_responses_output_format_from_mime_type(
|
||||
media_type.as_deref().unwrap_or("image/png"),
|
||||
)),
|
||||
);
|
||||
if let Some(data) = data.as_ref().filter(|value| !value.trim().is_empty()) {
|
||||
item.insert("result".to_string(), Value::String(data.clone()));
|
||||
} else if let Some(url) = url.as_ref().filter(|value| !value.trim().is_empty()) {
|
||||
item.insert("url".to_string(), Value::String(url.clone()));
|
||||
} else {
|
||||
item.insert("result".to_string(), Value::String(String::new()));
|
||||
}
|
||||
Value::Object(item)
|
||||
}
|
||||
|
||||
fn openai_responses_output_format_from_mime_type(mime_type: &str) -> String {
|
||||
match mime_type.trim().to_ascii_lowercase().as_str() {
|
||||
"image/jpeg" | "image/jpg" => "jpeg",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
_ => "png",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use serde_json::{json, Map, Number, Value};
|
||||
|
||||
use crate::formats::openai::responses::codex::CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT;
|
||||
use crate::formats::shared::model_directives::extract_gemini_model_from_path;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -32,11 +31,6 @@ pub fn build_gemini_image_request_body_from_openai_image_request(
|
||||
}
|
||||
|
||||
let prompt = normalized_request_prompt(normalized_request)
|
||||
.or_else(|| {
|
||||
(normalized_request.operation
|
||||
== crate::formats::openai::image::request::OpenAiImageOperation::Variation)
|
||||
.then(|| CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "Generate a high quality image.".to_string());
|
||||
let mut parts = Vec::new();
|
||||
if !prompt.trim().is_empty() {
|
||||
@@ -393,20 +387,7 @@ pub fn build_openai_image_response_from_response_stream_sync_body(
|
||||
let output = provider_body_json.get("output").and_then(Value::as_array)?;
|
||||
let images = output
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
|
||||
return None;
|
||||
}
|
||||
let b64_json = item
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
Some(json!({
|
||||
"b64_json": b64_json,
|
||||
"revised_prompt": item.get("revised_prompt").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
})
|
||||
.filter_map(openai_response_image_generation_item_to_image_data)
|
||||
.collect::<Vec<_>>();
|
||||
if images.is_empty() {
|
||||
return None;
|
||||
@@ -439,6 +420,48 @@ pub fn build_openai_image_response_from_response_stream_sync_body(
|
||||
Some(Value::Object(response))
|
||||
}
|
||||
|
||||
fn openai_response_image_generation_item_to_image_data(item: &Value) -> Option<Value> {
|
||||
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
|
||||
return None;
|
||||
}
|
||||
let result = item
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let url = item
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let mut image = Map::new();
|
||||
match result {
|
||||
Some(value) if value.starts_with("data:") => {
|
||||
let (_, b64_json) = parse_data_url(value)?;
|
||||
image.insert("b64_json".to_string(), Value::String(b64_json));
|
||||
}
|
||||
Some(value) if value.starts_with("http://") || value.starts_with("https://") => {
|
||||
image.insert("url".to_string(), Value::String(value.to_string()));
|
||||
}
|
||||
Some(value) => {
|
||||
image.insert("b64_json".to_string(), Value::String(value.to_string()));
|
||||
}
|
||||
None => {
|
||||
let url = url?;
|
||||
if let Some((_, b64_json)) = parse_data_url(url) {
|
||||
image.insert("b64_json".to_string(), Value::String(b64_json));
|
||||
} else {
|
||||
image.insert("url".to_string(), Value::String(url.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
image.insert(
|
||||
"revised_prompt".to_string(),
|
||||
item.get("revised_prompt").cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
Some(Value::Object(image))
|
||||
}
|
||||
|
||||
pub fn build_openai_image_provider_body_from_response_stream_sync_body(
|
||||
provider_body_json: &Value,
|
||||
report_context: Option<&Value>,
|
||||
@@ -882,7 +905,9 @@ mod tests {
|
||||
build_gemini_image_request_body_from_openai_image_request,
|
||||
build_gemini_image_response_from_openai_image_response,
|
||||
build_openai_image_request_body_from_gemini_image_request,
|
||||
build_openai_image_response_from_gemini_response, gemini_request_is_image_generation,
|
||||
build_openai_image_response_from_gemini_response,
|
||||
build_openai_image_response_from_response_stream_sync_body,
|
||||
gemini_request_is_image_generation,
|
||||
};
|
||||
use crate::formats::openai::image::request::normalize_openai_image_request;
|
||||
|
||||
@@ -1013,6 +1038,29 @@ mod tests {
|
||||
assert_eq!(converted["usage"]["total_tokens"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_responses_image_generation_url_to_openai_image_url() {
|
||||
let converted = build_openai_image_response_from_response_stream_sync_body(
|
||||
&json!({
|
||||
"created_at": 1776839946,
|
||||
"model": "gpt-image-2",
|
||||
"output": [{
|
||||
"type": "image_generation_call",
|
||||
"status": "completed",
|
||||
"url": "https://assets.example/generated.png"
|
||||
}]
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.expect("response image output should convert");
|
||||
|
||||
assert_eq!(
|
||||
converted["data"][0]["url"],
|
||||
"https://assets.example/generated.png"
|
||||
);
|
||||
assert!(converted["data"][0].get("b64_json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_openai_image_response_to_gemini_image_response() {
|
||||
let converted = build_gemini_image_response_from_openai_image_response(
|
||||
|
||||
@@ -193,10 +193,7 @@ pub fn resolve_execution_runtime_sync_plan_kind(
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("image")
|
||||
&& *method == Method::POST
|
||||
&& matches!(
|
||||
path,
|
||||
"/v1/images/generations" | "/v1/images/edits" | "/v1/images/variations"
|
||||
)
|
||||
&& matches!(path, "/v1/images/generations" | "/v1/images/edits")
|
||||
{
|
||||
return Some(OPENAI_IMAGE_SYNC_PLAN_KIND);
|
||||
}
|
||||
@@ -761,7 +758,7 @@ mod tests {
|
||||
&Method::POST,
|
||||
"/v1/images/variations",
|
||||
),
|
||||
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
|
||||
None
|
||||
);
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND
|
||||
|
||||
@@ -160,6 +160,57 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
|
||||
})
|
||||
}
|
||||
|
||||
pub fn content_part_from_openai_image_generation_item(
|
||||
item: &Value,
|
||||
) -> Option<CanonicalContentPart> {
|
||||
let item = item.as_object()?;
|
||||
let result = item
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let url = item
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let image = if let Some(result) = result {
|
||||
if result.starts_with("data:image/")
|
||||
|| result.starts_with("http://")
|
||||
|| result.starts_with("https://")
|
||||
{
|
||||
result.to_string()
|
||||
} else {
|
||||
let mime_type = item
|
||||
.get("mime_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
item.get("output_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(openai_image_output_format_to_mime_type)
|
||||
})
|
||||
.unwrap_or_else(|| "image/png".to_string());
|
||||
format!("data:{mime_type};base64,{result}")
|
||||
}
|
||||
} else {
|
||||
url?.to_string()
|
||||
};
|
||||
Some(CanonicalContentPart::ImageUrl(image))
|
||||
}
|
||||
|
||||
fn openai_image_output_format_to_mime_type(output_format: &str) -> String {
|
||||
match output_format.trim().to_ascii_lowercase().as_str() {
|
||||
"jpeg" | "jpg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
_ => "image/png",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
|
||||
@@ -22,8 +22,8 @@ use crate::formats::gemini::generate_content::stream::GeminiProviderState;
|
||||
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
|
||||
use crate::formats::shared::response::remove_empty_pages_from_tool_arguments;
|
||||
use crate::formats::shared::stream_core::common::{
|
||||
map_openai_finish_reason_to_gemini, parse_json_arguments_value, CanonicalContentPart,
|
||||
CanonicalStreamEvent, CanonicalUsage,
|
||||
content_part_from_openai_image_generation_item, map_openai_finish_reason_to_gemini,
|
||||
parse_json_arguments_value, CanonicalContentPart, CanonicalStreamEvent, CanonicalUsage,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1577,6 +1577,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
let mut message_states: BTreeMap<usize, OpenAIResponsesSyncMessageState> = BTreeMap::new();
|
||||
let mut reasoning_states: BTreeMap<usize, OpenAIResponsesSyncReasoningState> = BTreeMap::new();
|
||||
let mut tool_states: BTreeMap<usize, OpenAIResponsesSyncToolState> = BTreeMap::new();
|
||||
let mut image_items: BTreeMap<usize, Value> = BTreeMap::new();
|
||||
let mut item_output_indexes = BTreeMap::<String, usize>::new();
|
||||
|
||||
for event in events {
|
||||
@@ -1734,6 +1735,9 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
item,
|
||||
);
|
||||
}
|
||||
"image_generation_call" => {
|
||||
image_items.insert(output_index, Value::Object(item.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1833,6 +1837,9 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
item,
|
||||
);
|
||||
}
|
||||
"image_generation_call" => {
|
||||
image_items.insert(output_index, Value::Object(item.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1874,6 +1881,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
.keys()
|
||||
.chain(reasoning_states.keys())
|
||||
.chain(tool_states.keys())
|
||||
.chain(image_items.keys())
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
output_indexes.sort_unstable();
|
||||
@@ -1897,6 +1905,9 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
if let Some(state) = tool_states.remove(&output_index) {
|
||||
output.push(materialize_openai_responses_tool_item(output_index, state));
|
||||
}
|
||||
if let Some(item) = image_items.remove(&output_index) {
|
||||
output.push(item);
|
||||
}
|
||||
}
|
||||
response.insert("output".to_string(), Value::Array(output));
|
||||
}
|
||||
@@ -2541,6 +2552,11 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
|
||||
CanonicalStreamEvent::ContentPart(part) => {
|
||||
parts.push(gemini_sync_part_from_canonical_content_part(part));
|
||||
}
|
||||
CanonicalStreamEvent::ImageGenerationCall { item, .. } => {
|
||||
if let Some(part) = content_part_from_openai_image_generation_item(&item) {
|
||||
parts.push(gemini_sync_part_from_canonical_content_part(part));
|
||||
}
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
@@ -3464,6 +3480,25 @@ mod tests {
|
||||
assert_eq!(result["output"][0]["content"][0]["text"], "Authoritative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_openai_responses_image_generation_call_from_output_item_done() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_image_123\",\"object\":\"response\",\"model\":\"gpt-5.4-mini\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
|
||||
"event: response.output_item.done\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"status\":\"completed\",\"output_format\":\"png\",\"result\":\"aGVsbG8=\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_123\",\"object\":\"response\",\"model\":\"gpt-5.4-mini\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
|
||||
);
|
||||
|
||||
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
|
||||
.expect("openai-responses stream should aggregate into a sync body");
|
||||
|
||||
assert_eq!(result["output"][0]["type"], "image_generation_call");
|
||||
assert_eq!(result["output"][0]["result"], "aGVsbG8=");
|
||||
assert_eq!(result["output"][0]["output_format"], "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_openai_responses_multi_part_message_content_order() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -1762,6 +1762,11 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
|
||||
),
|
||||
});
|
||||
}
|
||||
"image_generation_call" => {
|
||||
blocks.push(openai_responses_image_generation_call_to_block(
|
||||
item_object,
|
||||
)?);
|
||||
}
|
||||
"output_text" | "text" | "output_image" | "image_url" | "file" | "input_file"
|
||||
| "input_audio" => blocks.push(openai_responses_part_to_canonical_block(item)?),
|
||||
_ => blocks.push(CanonicalContentBlock::Unknown {
|
||||
@@ -1774,6 +1779,79 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
|
||||
Some(blocks)
|
||||
}
|
||||
|
||||
fn openai_responses_image_generation_call_to_block(
|
||||
item_object: &Map<String, Value>,
|
||||
) -> Option<CanonicalContentBlock> {
|
||||
let result = item_object
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let url = item_object
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let raw_image = result.or(url)?;
|
||||
let fallback_media_type = item_object
|
||||
.get("mime_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
item_object
|
||||
.get("output_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(openai_responses_output_format_to_mime_type)
|
||||
});
|
||||
let (media_type, data, url) = if raw_image.starts_with("data:image/") {
|
||||
split_data_url(Some(raw_image.to_string()), fallback_media_type)
|
||||
} else if raw_image.starts_with("http://") || raw_image.starts_with("https://") {
|
||||
(fallback_media_type, None, Some(raw_image.to_string()))
|
||||
} else if result.is_some() {
|
||||
(
|
||||
fallback_media_type.or_else(|| Some("image/png".to_string())),
|
||||
Some(raw_image.to_string()),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
(fallback_media_type, None, Some(raw_image.to_string()))
|
||||
};
|
||||
let mut extensions = openai_responses_extensions(
|
||||
item_object,
|
||||
&[
|
||||
"type",
|
||||
"id",
|
||||
"status",
|
||||
"action",
|
||||
"result",
|
||||
"url",
|
||||
"output_format",
|
||||
"mime_type",
|
||||
],
|
||||
);
|
||||
canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE).insert(
|
||||
"item_type".to_string(),
|
||||
Value::String("image_generation_call".to_string()),
|
||||
);
|
||||
Some(CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
detail: None,
|
||||
extensions,
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_responses_output_format_to_mime_type(output_format: &str) -> String {
|
||||
match output_format.trim().to_ascii_lowercase().as_str() {
|
||||
"jpeg" | "jpg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
_ => "image/png",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn openai_responses_part_to_canonical_block(
|
||||
part: &Value,
|
||||
) -> Option<CanonicalContentBlock> {
|
||||
@@ -5327,6 +5405,48 @@ mod tests {
|
||||
assert_eq!(rebuilt["service_tier"], "flex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_image_generation_call_becomes_canonical_image_block() {
|
||||
let response = json!({
|
||||
"id": "resp_img",
|
||||
"model": "gpt-image-2",
|
||||
"status": "completed",
|
||||
"output": [{
|
||||
"id": "ig_1",
|
||||
"type": "image_generation_call",
|
||||
"status": "completed",
|
||||
"output_format": "png",
|
||||
"result": "aW1hZ2U="
|
||||
}]
|
||||
});
|
||||
|
||||
let canonical =
|
||||
from_openai_responses_to_canonical_response(&response).expect("canonical response");
|
||||
assert!(matches!(
|
||||
canonical.content[0],
|
||||
CanonicalContentBlock::Image { ref data, ref media_type, .. }
|
||||
if data.as_deref() == Some("aW1hZ2U=")
|
||||
&& media_type.as_deref() == Some("image/png")
|
||||
));
|
||||
|
||||
let rebuilt_chat = canonical_to_openai_chat_response(&canonical);
|
||||
assert_eq!(
|
||||
rebuilt_chat["choices"][0]["message"]["content"][0]["type"],
|
||||
json!("image_url")
|
||||
);
|
||||
assert_eq!(
|
||||
rebuilt_chat["choices"][0]["message"]["content"][0]["image_url"]["url"],
|
||||
json!("data:image/png;base64,aW1hZ2U=")
|
||||
);
|
||||
|
||||
let rebuilt_responses = canonical_to_openai_responses_response(&canonical, &json!({}));
|
||||
assert_eq!(
|
||||
rebuilt_responses["output"][0]["type"],
|
||||
json!("image_generation_call")
|
||||
);
|
||||
assert_eq!(rebuilt_responses["output"][0]["result"], json!("aW1hZ2U="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_request_adapter_preserves_cache_thinking_tools_and_builtin_extensions() {
|
||||
let request = json!({
|
||||
|
||||
@@ -38,6 +38,10 @@ pub enum CanonicalStreamEvent {
|
||||
ReasoningSummaryDone,
|
||||
ReasoningSignature(String),
|
||||
ContentPart(CanonicalContentPart),
|
||||
ImageGenerationCall {
|
||||
index: usize,
|
||||
item: Value,
|
||||
},
|
||||
ToolCallStart {
|
||||
index: usize,
|
||||
call_id: String,
|
||||
|
||||
@@ -10,9 +10,9 @@ pub use frame::{StreamFrame, StreamFramePayload, StreamFrameType};
|
||||
pub use plan::{
|
||||
ExecutionPlan, ExecutionTimeouts, ProxySnapshot, RequestBody, ResolvedTransportProfile,
|
||||
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,
|
||||
EXECUTION_REQUEST_HTTP1_ONLY_HEADER, TRANSPORT_BACKEND_BROWSER_WREQ,
|
||||
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};
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct ProxySnapshot {
|
||||
|
||||
pub const TRANSPORT_BACKEND_REQWEST_RUSTLS: &str = "reqwest_rustls";
|
||||
pub const TRANSPORT_BACKEND_HYPER_RUSTLS: &str = "hyper_rustls";
|
||||
pub const TRANSPORT_BACKEND_BROWSER_WREQ: &str = "browser_wreq";
|
||||
pub const TRANSPORT_HTTP_MODE_AUTO: &str = "auto";
|
||||
pub const TRANSPORT_HTTP_MODE_HTTP1_ONLY: &str = "http1_only";
|
||||
pub const TRANSPORT_POOL_SCOPE_KEY: &str = "key";
|
||||
|
||||
@@ -316,6 +316,13 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
|
||||
"gemini_cli" | "antigravity" => {
|
||||
auth_type == "oauth" && api_format == "gemini:generate_content"
|
||||
}
|
||||
"grok" => {
|
||||
auth_type == "oauth"
|
||||
&& matches!(
|
||||
api_format.as_str(),
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key" && api_format == "gemini:generate_content")
|
||||
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
||||
@@ -419,6 +426,35 @@ mod tests {
|
||||
assert_eq!(rows[0].provider_id, "provider-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn includes_grok_oauth_rows_for_chat_models() {
|
||||
let mut row = sample_row(
|
||||
"provider-grok",
|
||||
"openai:chat",
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
10,
|
||||
);
|
||||
row.provider_type = "grok".to_string();
|
||||
row.provider_name = "grok".to_string();
|
||||
row.key_auth_type = "oauth".to_string();
|
||||
row.key_api_formats = Some(vec![
|
||||
"openai:chat".to_string(),
|
||||
"openai:responses".to_string(),
|
||||
"claude:messages".to_string(),
|
||||
"openai:image".to_string(),
|
||||
]);
|
||||
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![row]);
|
||||
|
||||
let rows = repository
|
||||
.list_for_exact_api_format("openai:chat")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].provider_type, "grok");
|
||||
assert_eq!(rows[0].global_model_name, "grok-4.20-0309-non-reasoning");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requested_model_filter_respects_endpoint_scoped_default_mapping() {
|
||||
let mut selected = sample_row("provider-1", "openai:chat", "deepseek-v4-pro", 10);
|
||||
|
||||
@@ -445,6 +445,13 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"gemini_cli" | "antigravity" => {
|
||||
auth_type == "oauth" && api_format == "gemini:generate_content"
|
||||
}
|
||||
"grok" => {
|
||||
auth_type == "oauth"
|
||||
&& matches!(
|
||||
api_format.as_str(),
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key" && api_format == "gemini:generate_content")
|
||||
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
||||
|
||||
@@ -103,6 +103,11 @@ WHERE p.is_active = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'grok'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($3) IN ('openai:chat', 'openai:responses', 'claude:messages', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
@@ -127,6 +132,7 @@ WHERE p.is_active = TRUE
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
@@ -286,6 +292,11 @@ WHERE p.is_active = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'grok'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($4) IN ('openai:chat', 'openai:responses', 'claude:messages', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
@@ -310,6 +321,7 @@ WHERE p.is_active = TRUE
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
@@ -468,6 +480,11 @@ WHERE p.is_active = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'grok'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($6) IN ('openai:chat', 'openai:responses', 'claude:messages', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
@@ -492,6 +509,7 @@ WHERE p.is_active = TRUE
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
@@ -1287,6 +1305,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_selection_sql_allows_grok_oauth_chat_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)) = 'grok'"));
|
||||
assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) = 'oauth'"));
|
||||
assert!(sql
|
||||
.contains("'openai:chat', 'openai:responses', 'claude:messages', 'openai:image'"));
|
||||
assert!(sql.contains("'grok',"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_model_selection_page_sql_adds_limit_and_offset() {
|
||||
let sql = requested_model_selection_page_sql();
|
||||
|
||||
@@ -824,6 +824,13 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"gemini_cli" | "antigravity" => {
|
||||
auth_type == "oauth" && api_format == "gemini:generate_content"
|
||||
}
|
||||
"grok" => {
|
||||
auth_type == "oauth"
|
||||
&& matches!(
|
||||
api_format.as_str(),
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key" && api_format == "gemini:generate_content")
|
||||
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
||||
|
||||
@@ -229,7 +229,7 @@ pub fn endpoint_supports_rust_models_fetch(api_format: &str) -> bool {
|
||||
pub fn provider_type_uses_preset_models(provider_type: &str) -> bool {
|
||||
matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"claude_code" | "gemini_cli"
|
||||
"claude_code" | "gemini_cli" | "grok"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -272,6 +272,27 @@ pub fn preset_models_for_provider(provider_type: &str) -> Option<Vec<Value>> {
|
||||
preset_model("gpt-5.3-codex", "openai", "GPT-5.3 Codex", "openai:responses"),
|
||||
preset_model("gpt-5.3-codex-spark", "openai", "GPT-5.3 Codex Spark", "openai:responses"),
|
||||
],
|
||||
"grok" => vec![
|
||||
preset_model("grok-4.20-0309-non-reasoning", "xai", "Grok 4.20 0309 Non-Reasoning", "openai:chat"),
|
||||
preset_model("grok-4.20-0309", "xai", "Grok 4.20 0309", "openai:chat"),
|
||||
preset_model("grok-4.20-0309-reasoning", "xai", "Grok 4.20 0309 Reasoning", "openai:chat"),
|
||||
preset_model("grok-4.20-0309-non-reasoning-super", "xai", "Grok 4.20 0309 Non-Reasoning Super", "openai:chat"),
|
||||
preset_model("grok-4.20-0309-super", "xai", "Grok 4.20 0309 Super", "openai:chat"),
|
||||
preset_model("grok-4.20-0309-reasoning-super", "xai", "Grok 4.20 0309 Reasoning Super", "openai:chat"),
|
||||
preset_model("grok-4.20-0309-non-reasoning-heavy", "xai", "Grok 4.20 0309 Non-Reasoning Heavy", "openai:chat"),
|
||||
preset_model("grok-4.20-0309-heavy", "xai", "Grok 4.20 0309 Heavy", "openai:chat"),
|
||||
preset_model("grok-4.20-0309-reasoning-heavy", "xai", "Grok 4.20 0309 Reasoning Heavy", "openai:chat"),
|
||||
preset_model("grok-4.20-multi-agent-0309", "xai", "Grok 4.20 Multi-Agent 0309", "openai:chat"),
|
||||
preset_model("grok-4.20-auto", "xai", "Grok 4.20 Auto", "openai:chat"),
|
||||
preset_model("grok-4.20-fast", "xai", "Grok 4.20 Fast", "openai:chat"),
|
||||
preset_model("grok-4.20-expert", "xai", "Grok 4.20 Expert", "openai:chat"),
|
||||
preset_model("grok-4.20-heavy", "xai", "Grok 4.20 Heavy", "openai:chat"),
|
||||
preset_model("grok-4.3-beta", "xai", "Grok 4.3 Beta", "openai:chat"),
|
||||
preset_model("grok-imagine-image-lite", "xai", "Grok Imagine Image Lite", "openai:image"),
|
||||
preset_model("grok-imagine-image", "xai", "Grok Imagine Image", "openai:image"),
|
||||
preset_model("grok-imagine-image-pro", "xai", "Grok Imagine Image Pro", "openai:image"),
|
||||
preset_model("grok-imagine-image-edit", "xai", "Grok Imagine Image Edit", "openai:image"),
|
||||
],
|
||||
_ => return None,
|
||||
};
|
||||
Some(models)
|
||||
@@ -934,4 +955,42 @@ mod tests {
|
||||
.iter()
|
||||
.all(|model| model["api_formats"] == json!(["claude:messages"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_models_cover_grok_non_video_catalog() {
|
||||
let models = preset_models_for_provider("grok").expect("preset models should exist");
|
||||
let model_ids = models
|
||||
.iter()
|
||||
.map(|model| model["id"].as_str().expect("model id"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
model_ids,
|
||||
vec![
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"grok-4.20-0309",
|
||||
"grok-4.20-0309-reasoning",
|
||||
"grok-4.20-0309-non-reasoning-super",
|
||||
"grok-4.20-0309-super",
|
||||
"grok-4.20-0309-reasoning-super",
|
||||
"grok-4.20-0309-non-reasoning-heavy",
|
||||
"grok-4.20-0309-heavy",
|
||||
"grok-4.20-0309-reasoning-heavy",
|
||||
"grok-4.20-multi-agent-0309",
|
||||
"grok-4.20-auto",
|
||||
"grok-4.20-fast",
|
||||
"grok-4.20-expert",
|
||||
"grok-4.20-heavy",
|
||||
"grok-4.3-beta",
|
||||
"grok-imagine-image-lite",
|
||||
"grok-imagine-image",
|
||||
"grok-imagine-image-pro",
|
||||
"grok-imagine-image-edit",
|
||||
]
|
||||
);
|
||||
assert!(!model_ids.contains(&"grok-imagine-video"));
|
||||
assert_eq!(models[0]["api_formats"], json!(["openai:chat"]));
|
||||
assert_eq!(models[10]["api_formats"], json!(["openai:chat"]));
|
||||
assert_eq!(models[15]["api_formats"], json!(["openai:image"]));
|
||||
assert_eq!(models[18]["api_formats"], json!(["openai:image"]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||
pub use providers::{
|
||||
build_antigravity_pool_quota_request, build_chatgpt_web_pool_quota_request,
|
||||
build_codex_pool_quota_request, build_kiro_pool_quota_request,
|
||||
enrich_chatgpt_web_quota_metadata, normalize_chatgpt_web_image_quota_limit,
|
||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
||||
DefaultProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
|
||||
enrich_chatgpt_web_quota_metadata, grok_mode_id_for_model, grok_pool_tier_from_quota_bucket,
|
||||
grok_quota_window_key_for_model, grok_supported_quota_windows_for_tier,
|
||||
normalize_chatgpt_web_image_quota_limit, AntigravityProviderPoolAdapter,
|
||||
ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter, DefaultProviderPoolAdapter,
|
||||
GrokProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
|
||||
UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
|
||||
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL,
|
||||
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
|
||||
@@ -65,6 +67,7 @@ mod tests {
|
||||
"claude_code",
|
||||
"codex",
|
||||
"gemini_cli",
|
||||
"grok",
|
||||
"kiro",
|
||||
"vertex_ai"
|
||||
]
|
||||
@@ -82,10 +85,11 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
service.provider_types_for_capability(ProviderPoolCapability::QuotaRefresh),
|
||||
["antigravity", "chatgpt_web", "codex", "kiro"]
|
||||
["antigravity", "chatgpt_web", "codex", "grok", "kiro"]
|
||||
);
|
||||
assert!(service.supports_quota_refresh("codex"));
|
||||
assert!(service.supports_quota_refresh("antigravity"));
|
||||
assert!(service.supports_quota_refresh("grok"));
|
||||
assert!(!service.supports_quota_refresh("gemini_cli"));
|
||||
assert_eq!(
|
||||
service.quota_refresh_unsupported_message("claude_code"),
|
||||
@@ -247,8 +251,11 @@ mod tests {
|
||||
.find(|item| item["name"] == "recent_refresh")
|
||||
.expect("recent_refresh should exist");
|
||||
|
||||
assert_eq!(free_first["providers"], json!(["codex", "kiro"]));
|
||||
assert_eq!(recent_refresh["providers"], json!(["codex", "kiro"]));
|
||||
assert_eq!(free_first["providers"], json!(["codex", "grok", "kiro"]));
|
||||
assert_eq!(
|
||||
recent_refresh["providers"],
|
||||
json!(["codex", "grok", "kiro"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -331,6 +338,37 @@ mod tests {
|
||||
}))),
|
||||
"chatgpt_web",
|
||||
));
|
||||
assert!(provider_pool_key_account_quota_exhausted(
|
||||
&sample_key(Some(json!({
|
||||
"grok": {
|
||||
"quota_by_model": {
|
||||
"quota_fast": {
|
||||
"is_exhausted": true,
|
||||
"remaining": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}))),
|
||||
"grok",
|
||||
));
|
||||
assert!(!provider_pool_key_account_quota_exhausted(
|
||||
&sample_key(Some(json!({
|
||||
"grok": {
|
||||
"pool_tier": "basic",
|
||||
"quota_by_model": {
|
||||
"quota_fast": {
|
||||
"is_exhausted": false,
|
||||
"remaining": 1.0
|
||||
},
|
||||
"quota_heavy": {
|
||||
"is_exhausted": true,
|
||||
"remaining": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}))),
|
||||
"grok",
|
||||
));
|
||||
assert!(!provider_pool_key_account_quota_exhausted(
|
||||
&sample_key(Some(json!({
|
||||
"codex": {
|
||||
@@ -342,6 +380,68 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_quota_tier_boundaries_match_pool_modes() {
|
||||
assert_eq!(
|
||||
grok_supported_quota_windows_for_tier(Some("basic")),
|
||||
[("quota_fast", "fast")]
|
||||
);
|
||||
assert_eq!(
|
||||
grok_supported_quota_windows_for_tier(Some("super")),
|
||||
[
|
||||
("quota_auto", "auto"),
|
||||
("quota_fast", "fast"),
|
||||
("quota_expert", "expert"),
|
||||
("quota_grok_4_3", "grok-420-computer-use-sa")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
grok_supported_quota_windows_for_tier(Some("heavy")),
|
||||
[
|
||||
("quota_auto", "auto"),
|
||||
("quota_fast", "fast"),
|
||||
("quota_expert", "expert"),
|
||||
("quota_heavy", "heavy"),
|
||||
("quota_grok_4_3", "grok-420-computer-use-sa")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_pool_tier_infers_from_live_quota_totals() {
|
||||
let bucket = json!({
|
||||
"quota_by_model": {
|
||||
"quota_fast": {
|
||||
"remaining": 20.0,
|
||||
"total": 30.0
|
||||
},
|
||||
"quota_auto": {
|
||||
"remaining": 7.0,
|
||||
"total": 7.0
|
||||
}
|
||||
}
|
||||
});
|
||||
let bucket = bucket.as_object().expect("bucket should be object");
|
||||
|
||||
assert_eq!(grok_pool_tier_from_quota_bucket(bucket), Some("basic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_model_name_maps_to_quota_window() {
|
||||
assert_eq!(
|
||||
grok_quota_window_key_for_model(Some("grok-4.20-fast")),
|
||||
Some("quota_fast")
|
||||
);
|
||||
assert_eq!(
|
||||
grok_quota_window_key_for_model(Some("grok-4.20-multi-agent-0309")),
|
||||
Some("quota_heavy")
|
||||
);
|
||||
assert_eq!(
|
||||
grok_quota_window_key_for_model(Some("grok-4.3-beta")),
|
||||
Some("quota_grok_4_3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_tier_derivation_normalizes_provider_prefix() {
|
||||
let key = sample_key(Some(json!({
|
||||
|
||||
220
crates/aether-provider-pool/src/providers/grok.rs
Normal file
220
crates/aether-provider-pool/src/providers/grok.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::capability::ProviderPoolCapabilities;
|
||||
use crate::provider::{
|
||||
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
|
||||
ProviderPoolMemberInput,
|
||||
};
|
||||
use crate::quota::{
|
||||
provider_pool_json_bool, provider_pool_json_f64, provider_pool_metadata_bucket,
|
||||
provider_pool_quota_snapshot_exhausted_decision,
|
||||
};
|
||||
|
||||
pub const GROK_QUOTA_WINDOWS_BASIC: &[(&str, &str)] = &[("quota_fast", "fast")];
|
||||
pub const GROK_QUOTA_WINDOWS_SUPER: &[(&str, &str)] = &[
|
||||
("quota_auto", "auto"),
|
||||
("quota_fast", "fast"),
|
||||
("quota_expert", "expert"),
|
||||
("quota_grok_4_3", "grok-420-computer-use-sa"),
|
||||
];
|
||||
pub const GROK_QUOTA_WINDOWS_HEAVY: &[(&str, &str)] = &[
|
||||
("quota_auto", "auto"),
|
||||
("quota_fast", "fast"),
|
||||
("quota_expert", "expert"),
|
||||
("quota_heavy", "heavy"),
|
||||
("quota_grok_4_3", "grok-420-computer-use-sa"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GrokProviderPoolAdapter;
|
||||
|
||||
impl ProviderPoolAdapter for GrokProviderPoolAdapter {
|
||||
fn provider_type(&self) -> &'static str {
|
||||
"grok"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderPoolCapabilities {
|
||||
ProviderPoolCapabilities {
|
||||
plan_tier: true,
|
||||
quota_reset: true,
|
||||
quota_refresh: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
|
||||
if let Some(exhausted) =
|
||||
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
|
||||
{
|
||||
return exhausted;
|
||||
}
|
||||
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
|
||||
.is_some_and(quota_exhausted_from_bucket)
|
||||
}
|
||||
|
||||
fn quota_refresh_endpoint(
|
||||
&self,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
include_inactive: bool,
|
||||
) -> Option<StoredProviderCatalogEndpoint> {
|
||||
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||
provider_pool_endpoint_format_matches(endpoint, "openai:chat")
|
||||
})
|
||||
.or_else(|| provider_pool_matching_endpoint(endpoints, include_inactive, |_| true))
|
||||
}
|
||||
|
||||
fn quota_refresh_missing_endpoint_message(&self) -> String {
|
||||
"找不到有效的 Grok 端点".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn grok_supported_quota_windows_for_tier(
|
||||
tier: Option<&str>,
|
||||
) -> &'static [(&'static str, &'static str)] {
|
||||
match grok_normalize_pool_tier(tier) {
|
||||
Some("basic") => GROK_QUOTA_WINDOWS_BASIC,
|
||||
Some("super") => GROK_QUOTA_WINDOWS_SUPER,
|
||||
Some("heavy") => GROK_QUOTA_WINDOWS_HEAVY,
|
||||
_ => GROK_QUOTA_WINDOWS_HEAVY,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn grok_pool_tier_from_quota_bucket(bucket: &Map<String, Value>) -> Option<&'static str> {
|
||||
if let Some(tier) = grok_normalize_pool_tier(
|
||||
grok_bucket_string(bucket, &["pool_tier", "tier", "plan_type", "plan"]).as_deref(),
|
||||
) {
|
||||
return Some(tier);
|
||||
}
|
||||
|
||||
if let Some(auto_total) = grok_quota_total(bucket, "quota_auto") {
|
||||
if (auto_total - 50.0).abs() < f64::EPSILON {
|
||||
return Some("super");
|
||||
}
|
||||
if (auto_total - 150.0).abs() < f64::EPSILON {
|
||||
return Some("heavy");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(fast_total) = grok_quota_total(bucket, "quota_fast") {
|
||||
if (fast_total - 30.0).abs() < f64::EPSILON {
|
||||
return Some("basic");
|
||||
}
|
||||
if (fast_total - 140.0).abs() < f64::EPSILON {
|
||||
return Some("super");
|
||||
}
|
||||
if (fast_total - 400.0).abs() < f64::EPSILON {
|
||||
return Some("heavy");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn grok_quota_window_key_for_model(model: Option<&str>) -> Option<&'static str> {
|
||||
Some(match grok_mode_id_for_model(model) {
|
||||
"fast" => "quota_fast",
|
||||
"auto" => "quota_auto",
|
||||
"expert" => "quota_expert",
|
||||
"heavy" => "quota_heavy",
|
||||
"grok-420-computer-use-sa" => "quota_grok_4_3",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn grok_mode_id_for_model(model: Option<&str>) -> &'static str {
|
||||
let model = model.unwrap_or_default().to_ascii_lowercase();
|
||||
if model.contains("4.3") || model.contains("computer") {
|
||||
"grok-420-computer-use-sa"
|
||||
} else if model.contains("multi-agent") {
|
||||
"heavy"
|
||||
} else if model.contains("non-reasoning") || model.contains("fast") || model.contains("lite") {
|
||||
"fast"
|
||||
} else if model.contains("expert") || model.contains("reasoning") {
|
||||
"expert"
|
||||
} else if model.contains("0309-heavy") {
|
||||
"auto"
|
||||
} else if model.contains("heavy") {
|
||||
"heavy"
|
||||
} else {
|
||||
"auto"
|
||||
}
|
||||
}
|
||||
|
||||
fn grok_normalize_pool_tier(value: Option<&str>) -> Option<&'static str> {
|
||||
match value?.trim().to_ascii_lowercase().as_str() {
|
||||
"basic" => Some("basic"),
|
||||
"super" => Some("super"),
|
||||
"heavy" => Some("heavy"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn grok_bucket_string(bucket: &Map<String, Value>, fields: &[&str]) -> Option<String> {
|
||||
fields.iter().find_map(|field| {
|
||||
bucket
|
||||
.get(*field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn grok_quota_total(quota_by_model: &Map<String, Value>, key: &str) -> Option<f64> {
|
||||
let models = quota_by_model
|
||||
.get("quota_by_model")
|
||||
.or_else(|| quota_by_model.get("models"))
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or(quota_by_model);
|
||||
models
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|quota| quota.get("total"))
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
}
|
||||
|
||||
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
|
||||
let models = bucket
|
||||
.get("quota_by_model")
|
||||
.or_else(|| bucket.get("models"))
|
||||
.and_then(Value::as_object);
|
||||
let Some(models) = models else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let supported_mode_keys =
|
||||
grok_supported_quota_windows_for_tier(grok_pool_tier_from_quota_bucket(bucket))
|
||||
.iter()
|
||||
.map(|(quota_key, _)| *quota_key)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut model_count = 0usize;
|
||||
let mut exhausted_count = 0usize;
|
||||
for (model_key, item) in models.iter() {
|
||||
if !supported_mode_keys.is_empty() && !supported_mode_keys.contains(&model_key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(item) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let has_quota_data = provider_pool_json_bool(item.get("is_exhausted")).is_some()
|
||||
|| provider_pool_json_f64(item.get("used_percent")).is_some()
|
||||
|| provider_pool_json_f64(item.get("remaining")).is_some()
|
||||
|| provider_pool_json_f64(item.get("remaining_fraction")).is_some();
|
||||
if !has_quota_data {
|
||||
continue;
|
||||
}
|
||||
model_count += 1;
|
||||
if provider_pool_json_bool(item.get("is_exhausted")) == Some(true)
|
||||
|| provider_pool_json_f64(item.get("used_percent")).is_some_and(|value| value >= 100.0)
|
||||
|| provider_pool_json_f64(item.get("remaining")).is_some_and(|value| value <= 0.0)
|
||||
|| provider_pool_json_f64(item.get("remaining_fraction"))
|
||||
.is_some_and(|value| value <= 0.0)
|
||||
{
|
||||
exhausted_count += 1;
|
||||
}
|
||||
}
|
||||
model_count > 0 && model_count == exhausted_count
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod antigravity;
|
||||
pub mod chatgpt_web;
|
||||
pub mod codex;
|
||||
pub mod default;
|
||||
pub mod grok;
|
||||
pub mod kiro;
|
||||
pub mod unsupported;
|
||||
|
||||
@@ -18,6 +19,10 @@ pub use chatgpt_web::{
|
||||
pub use codex::CodexProviderPoolAdapter;
|
||||
pub use codex::{build_codex_pool_quota_request, CODEX_WHAM_USAGE_URL};
|
||||
pub use default::DefaultProviderPoolAdapter;
|
||||
pub use grok::{
|
||||
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
|
||||
grok_supported_quota_windows_for_tier, GrokProviderPoolAdapter,
|
||||
};
|
||||
pub use kiro::KiroProviderPoolAdapter;
|
||||
pub use kiro::{
|
||||
build_kiro_pool_quota_request, KiroPoolQuotaAuthInput, KIRO_USAGE_LIMITS_PATH,
|
||||
|
||||
@@ -12,8 +12,9 @@ use crate::presets::normalize_provider_scheduling_presets;
|
||||
use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||
use crate::providers::{
|
||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
||||
DefaultProviderPoolAdapter, KiroProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
|
||||
GEMINI_CLI_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||
DefaultProviderPoolAdapter, GrokProviderPoolAdapter, KiroProviderPoolAdapter,
|
||||
CLAUDE_CODE_PROVIDER_POOL_ADAPTER, GEMINI_CLI_PROVIDER_POOL_ADAPTER,
|
||||
VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -50,6 +51,7 @@ impl ProviderPoolService {
|
||||
.with_adapter(Arc::new(CLAUDE_CODE_PROVIDER_POOL_ADAPTER))
|
||||
.with_adapter(Arc::new(CodexProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(GEMINI_CLI_PROVIDER_POOL_ADAPTER))
|
||||
.with_adapter(Arc::new(GrokProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(KiroProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(VERTEX_AI_PROVIDER_POOL_ADAPTER))
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::conversion::{
|
||||
request_conversion_enabled_for_transport, request_conversion_transport_unsupported_reason,
|
||||
request_pair_allowed_for_transport,
|
||||
};
|
||||
use crate::grok::grok_browser_resolved_transport_profile_from_auth_config;
|
||||
use crate::network::{
|
||||
resolve_transport_profile, resolve_transport_profile_id, transport_proxy_is_locally_supported,
|
||||
};
|
||||
@@ -89,6 +90,31 @@ pub fn build_transport_diagnostics(
|
||||
.and_then(|value| value.get("transport_profile"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let configured_legacy_grok_transport_profile = if transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("grok")
|
||||
{
|
||||
transport
|
||||
.key
|
||||
.decrypted_auth_config
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.and_then(|auth_config| {
|
||||
grok_browser_resolved_transport_profile_from_auth_config(
|
||||
&auth_config,
|
||||
"grok_auth_config",
|
||||
)
|
||||
.and_then(|profile| serde_json::to_value(profile).ok())
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
let has_oauth_config = transport.key.decrypted_auth_config.is_some();
|
||||
let oauth_resolution_supported =
|
||||
!has_oauth_config || crate::supports_local_oauth_request_auth_resolution(transport);
|
||||
@@ -126,6 +152,7 @@ pub fn build_transport_diagnostics(
|
||||
"fingerprint": transport.key.fingerprint,
|
||||
"configured_key_transport_profile": configured_key_transport_profile,
|
||||
"configured_provider_transport_profile": configured_provider_transport_profile,
|
||||
"configured_legacy_grok_transport_profile": configured_legacy_grok_transport_profile,
|
||||
"resolved_transport_profile_id": resolved_transport_profile_id,
|
||||
"resolved_transport_profile": resolved_transport_profile,
|
||||
"request_pair": {
|
||||
@@ -423,6 +450,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_grok_transport_with_legacy_user_agent() -> GatewayProviderTransportSnapshot {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = None;
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
transport
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_diagnostics_include_legacy_grok_transport_profile() {
|
||||
let diagnostics = build_transport_diagnostics(
|
||||
&sample_grok_transport_with_legacy_user_agent(),
|
||||
"openai:chat",
|
||||
"openai:chat",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
diagnostics["configured_legacy_grok_transport_profile"]["profile_id"],
|
||||
"chrome137"
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics["resolved_transport_profile"]["profile_id"],
|
||||
"chrome137"
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics["resolved_transport_profile"]["backend"],
|
||||
"browser_wreq"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_trace_proxy_value_sanitizes_url_and_marks_config_source() {
|
||||
let transport = sample_transport();
|
||||
|
||||
1320
crates/aether-provider-transport/src/grok.rs
Normal file
1320
crates/aether-provider-transport/src/grok.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ pub mod conversion;
|
||||
mod diagnostics;
|
||||
mod gemini_files;
|
||||
mod generic_oauth;
|
||||
pub mod grok;
|
||||
mod headers;
|
||||
pub mod kiro;
|
||||
mod network;
|
||||
@@ -44,6 +45,17 @@ pub use gemini_files::{
|
||||
pub use generic_oauth::{
|
||||
supports_local_generic_oauth_request_auth_resolution, GenericOAuthRefreshAdapter,
|
||||
};
|
||||
pub use grok::{
|
||||
build_grok_app_chat_body, build_grok_browser_headers, build_grok_upstream_url, grok_base_url,
|
||||
grok_browser_profile_id_from_user_agent,
|
||||
grok_browser_profile_metadata_from_resolved_transport_profile,
|
||||
grok_browser_resolved_transport_profile,
|
||||
grok_browser_resolved_transport_profile_from_auth_config,
|
||||
grok_browser_transport_fingerprint_from_auth_config, is_grok_provider_transport,
|
||||
resolve_grok_session_auth, GrokBrowserProfileMetadata, GrokHeaderInput, GROK_CHAT_PATH,
|
||||
GROK_DEFAULT_BASE_URL, GROK_DEFAULT_BROWSER_PROFILE, GROK_DEFAULT_USER_AGENT,
|
||||
GROK_INTERNAL_HEADER, GROK_RATE_LIMITS_PATH,
|
||||
};
|
||||
pub use headers::{should_skip_request_header, should_skip_upstream_passthrough_header};
|
||||
pub use network::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_profile, resolve_transport_profile_id,
|
||||
|
||||
@@ -6,6 +6,8 @@ use async_trait::async_trait;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::grok::grok_browser_resolved_transport_profile_from_auth_config;
|
||||
|
||||
use super::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
const TUNNEL_BASE_URL_EXTRA_KEY: &str = "tunnel_base_url";
|
||||
@@ -152,9 +154,39 @@ pub fn resolve_transport_profile(
|
||||
) -> Option<ResolvedTransportProfile> {
|
||||
resolve_transport_profile_from_fingerprint(transport.key.fingerprint.as_ref()).or_else(|| {
|
||||
resolve_transport_profile_from_provider_config(transport.provider.config.as_ref())
|
||||
.or_else(|| resolve_grok_browser_transport_profile(transport))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_grok_browser_transport_profile(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<ResolvedTransportProfile> {
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("grok")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let auth_config = transport
|
||||
.key
|
||||
.decrypted_auth_config
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())?;
|
||||
let object = auth_config.as_object()?;
|
||||
let has_session = json_string_field(object, "sso_token")
|
||||
.or_else(|| json_string_field(object, "access_token"))
|
||||
.or_else(|| json_string_field(object, "token"))
|
||||
.is_some();
|
||||
if !has_session {
|
||||
return None;
|
||||
}
|
||||
grok_browser_resolved_transport_profile_from_auth_config(object, "grok_auth_config")
|
||||
}
|
||||
|
||||
fn resolve_transport_profile_from_provider_config(
|
||||
config: Option<&Value>,
|
||||
) -> Option<ResolvedTransportProfile> {
|
||||
@@ -529,4 +561,218 @@ mod tests {
|
||||
assert!(resolve_transport_profile(&transport).is_none());
|
||||
assert!(!transport_profile_is_configured(&transport));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_grok_browser_transport_profile_from_session_auth_config() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = None;
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"browser_profile": "chrome136",
|
||||
"cf_clearance": "clearance"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let profile = resolve_transport_profile(&transport).expect("profile");
|
||||
|
||||
assert_eq!(profile.profile_id, "chrome136");
|
||||
assert_eq!(profile.backend, "browser_wreq");
|
||||
assert_eq!(profile.http_mode, "auto");
|
||||
assert_eq!(profile.pool_scope, "key");
|
||||
assert_eq!(
|
||||
profile
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("browser_profile"))
|
||||
.and_then(Value::as_str),
|
||||
Some("chrome136")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_grok_browser_transport_profile_default_from_session_auth_config() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = None;
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let profile = resolve_transport_profile(&transport).expect("profile");
|
||||
|
||||
assert_eq!(profile.profile_id, "chrome136");
|
||||
assert_eq!(profile.backend, "browser_wreq");
|
||||
assert_eq!(
|
||||
profile
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("source"))
|
||||
.and_then(Value::as_str),
|
||||
Some("grok_auth_config")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_grok_browser_transport_profile_normalizes_auth_config_alias() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = None;
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"browser_profile": "Chrome-137"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let profile = resolve_transport_profile(&transport).expect("profile");
|
||||
|
||||
assert_eq!(profile.profile_id, "chrome137");
|
||||
assert_eq!(
|
||||
profile
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("browser_profile"))
|
||||
.and_then(Value::as_str),
|
||||
Some("chrome137")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_grok_browser_transport_profile_from_legacy_user_agent() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = None;
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let profile = resolve_transport_profile(&transport).expect("profile");
|
||||
|
||||
assert_eq!(profile.profile_id, "chrome137");
|
||||
assert_eq!(
|
||||
profile
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("browser_profile"))
|
||||
.and_then(Value::as_str),
|
||||
Some("chrome137")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_fingerprint_wins_over_grok_auth_config_fallback() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.provider.config = None;
|
||||
transport.key.fingerprint = Some(json!({
|
||||
"transport_profile": {
|
||||
"profile_id": "chrome136",
|
||||
"backend": "browser_wreq",
|
||||
"extra": {"browser_profile": "chrome136", "source": "key"}
|
||||
}
|
||||
}));
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"browser_profile": "chrome137"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let profile = resolve_transport_profile(&transport).expect("profile");
|
||||
|
||||
assert_eq!(profile.profile_id, "chrome136");
|
||||
assert_eq!(
|
||||
profile
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("source"))
|
||||
.and_then(Value::as_str),
|
||||
Some("key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_fingerprint_wins_over_grok_auth_config_fallback() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = Some(json!({
|
||||
"fingerprint": {
|
||||
"transport_profile": {
|
||||
"profile_id": "chrome136",
|
||||
"backend": "browser_wreq",
|
||||
"extra": {"browser_profile": "chrome136", "source": "provider"}
|
||||
}
|
||||
}
|
||||
}));
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"browser_profile": "chrome137"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let profile = resolve_transport_profile(&transport).expect("profile");
|
||||
|
||||
assert_eq!(profile.profile_id, "chrome136");
|
||||
assert_eq!(
|
||||
profile
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("source"))
|
||||
.and_then(Value::as_str),
|
||||
Some("provider")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_grok_auth_config_browser_profile() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = None;
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"browser_profile": "safari999"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
assert!(resolve_transport_profile(&transport).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_grok_auth_config_user_agent_profile() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.fingerprint = None;
|
||||
transport.provider.config = None;
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"sso_token": "sso-token",
|
||||
"user_agent": "Mozilla/5.0 Version/18.0 Safari/605.1.15"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
assert!(resolve_transport_profile(&transport).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::{build_passthrough_headers_with_auth, resolve_local_openai_bearer_auth};
|
||||
use crate::grok::{is_grok_provider_transport, resolve_grok_session_auth};
|
||||
use crate::policy::local_standard_transport_unsupported_reason_with_network;
|
||||
use crate::rules::apply_local_header_rules_with_request_headers;
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
@@ -23,21 +24,33 @@ pub fn openai_image_transport_unsupported_reason(
|
||||
api_format: &str,
|
||||
) -> Option<&'static str> {
|
||||
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")
|
||||
if is_dedicated_openai_image_provider(transport)
|
||||
&& matches!(
|
||||
reason,
|
||||
Some("transport_provider_type_unsupported")
|
||||
| Some("transport_oauth_resolution_unsupported")
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
reason
|
||||
}
|
||||
|
||||
fn is_dedicated_openai_image_provider(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("chatgpt_web")
|
||||
|| is_grok_provider_transport(transport)
|
||||
}
|
||||
|
||||
pub fn resolve_openai_image_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<(String, String)> {
|
||||
if is_grok_provider_transport(transport) {
|
||||
return resolve_grok_session_auth(transport);
|
||||
}
|
||||
resolve_local_openai_bearer_auth(transport)
|
||||
}
|
||||
|
||||
@@ -159,6 +172,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_is_supported_by_dedicated_openai_image_transport_policy() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
|
||||
assert_eq!(
|
||||
openai_image_transport_unsupported_reason(&transport, "openai:image"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_oauth_session_is_supported_by_dedicated_openai_image_transport_policy() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "grok".to_string();
|
||||
transport.key.auth_type = "oauth".to_string();
|
||||
transport.key.decrypted_api_key = String::new();
|
||||
transport.key.decrypted_auth_config = Some(json!({"sso_token":"abc"}).to_string());
|
||||
|
||||
assert_eq!(
|
||||
openai_image_transport_unsupported_reason(&transport, "openai:image"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_oauth_is_supported_by_dedicated_openai_image_transport_policy() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = "chatgpt_web".to_string();
|
||||
transport.key.auth_type = "oauth".to_string();
|
||||
transport.key.decrypted_api_key = String::new();
|
||||
transport.key.decrypted_auth_config = Some(json!({"access_token":"token"}).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 {
|
||||
|
||||
@@ -238,6 +238,15 @@ const ANTIGRAVITY_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy
|
||||
supports_local_same_format_transport: false,
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
const GROK_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
fixed_provider: true,
|
||||
api_format_inheritance: ProviderApiFormatInheritance::OAuth,
|
||||
enable_format_conversion_by_default: true,
|
||||
supports_model_fetch: false,
|
||||
supports_local_openai_chat_transport: false,
|
||||
supports_local_same_format_transport: false,
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
|
||||
const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "claude_code",
|
||||
@@ -352,6 +361,39 @@ const ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProvider
|
||||
runtime_policy: ANTIGRAVITY_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
const GROK_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "grok",
|
||||
version: 1,
|
||||
base_url: "https://grok.com",
|
||||
endpoints: &[
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "openai:chat",
|
||||
api_format: "openai:chat",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "openai:responses",
|
||||
api_format: "openai:responses",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "claude:messages",
|
||||
api_format: "claude:messages",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "openai:image",
|
||||
api_format: "openai:image",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
],
|
||||
runtime_policy: GROK_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
pub fn provider_type_is_fixed(provider_type: &str) -> bool {
|
||||
provider_runtime_policy(provider_type).fixed_provider
|
||||
}
|
||||
@@ -398,6 +440,7 @@ pub fn fixed_provider_template(provider_type: &str) -> Option<&'static FixedProv
|
||||
"codex" => Some(&CODEX_FIXED_PROVIDER_TEMPLATE),
|
||||
"chatgpt_web" => Some(&CHATGPT_WEB_FIXED_PROVIDER_TEMPLATE),
|
||||
"kiro" => Some(&KIRO_FIXED_PROVIDER_TEMPLATE),
|
||||
"grok" => Some(&GROK_FIXED_PROVIDER_TEMPLATE),
|
||||
"gemini_cli" => Some(&GEMINI_CLI_FIXED_PROVIDER_TEMPLATE),
|
||||
"vertex_ai" => Some(&VERTEX_AI_FIXED_PROVIDER_TEMPLATE),
|
||||
"antigravity" => Some(&ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE),
|
||||
@@ -598,6 +641,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_fixed_provider_template_exposes_chat_responses_messages_and_image() {
|
||||
let template = fixed_provider_template("grok").expect("grok template should exist");
|
||||
assert_eq!(template.base_url, "https://grok.com");
|
||||
assert_eq!(template.version, 1);
|
||||
assert_eq!(
|
||||
template
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|item| item.api_format)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"openai:image"
|
||||
]
|
||||
);
|
||||
assert!(!template.runtime_policy.supports_model_fetch);
|
||||
assert!(!template.runtime_policy.supports_local_openai_chat_transport);
|
||||
assert!(!template.runtime_policy.supports_local_same_format_transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_provider_key_inheritance_keeps_oauth_and_kiro_configured_bearer_keys_open() {
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::auth::{
|
||||
};
|
||||
use crate::claude_code::build_claude_code_passthrough_headers;
|
||||
use crate::claude_code::local_claude_code_transport_unsupported_reason_with_network;
|
||||
use crate::grok::{is_grok_provider_transport, resolve_grok_session_auth};
|
||||
use crate::kiro::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body, is_kiro_provider_transport,
|
||||
local_kiro_request_transport_unsupported_reason_with_network, KiroAuthConfig,
|
||||
@@ -351,6 +352,10 @@ pub fn same_format_provider_transport_unsupported_reason(
|
||||
family: SameFormatProviderFamily,
|
||||
api_format: &str,
|
||||
) -> Option<&'static str> {
|
||||
if is_grok_provider_transport(transport) && matches!(family, SameFormatProviderFamily::Standard)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if behavior.is_kiro {
|
||||
local_kiro_request_transport_unsupported_reason_with_network(transport)
|
||||
} else if behavior.is_antigravity {
|
||||
@@ -434,6 +439,10 @@ pub fn resolve_same_format_provider_direct_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
family: SameFormatProviderFamily,
|
||||
) -> Option<(String, String)> {
|
||||
if is_grok_provider_transport(transport) && matches!(family, SameFormatProviderFamily::Standard)
|
||||
{
|
||||
return resolve_grok_session_auth(transport);
|
||||
}
|
||||
if behavior.is_vertex {
|
||||
None
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user