mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
feat(openai): unify tier authorization and settlement
This commit is contained in:
@@ -7,8 +7,8 @@ use crate::{
|
||||
protocol::canonical::{
|
||||
canonical_blocks_to_openai_chat_message, canonical_stop_reason_to_openai,
|
||||
canonical_usage_to_openai, openai_extensions, openai_finish_reason_to_canonical,
|
||||
openai_message_content_blocks, openai_usage_to_canonical, CanonicalContentBlock,
|
||||
CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
|
||||
openai_message_content_blocks, openai_service_tier_extension, openai_usage_to_canonical,
|
||||
CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
@@ -17,20 +17,8 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
|
||||
let mut body = to_raw(response);
|
||||
if body.get("service_tier").is_none() {
|
||||
if let Some(service_tier) = ctx
|
||||
.report_context_value()
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|request| request.get("service_tier"))
|
||||
.cloned()
|
||||
{
|
||||
body["service_tier"] = service_tier;
|
||||
}
|
||||
}
|
||||
Some(body)
|
||||
pub fn to(response: &CanonicalResponse, _ctx: &FormatContext) -> Option<Value> {
|
||||
Some(to_raw(response))
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
@@ -186,17 +174,7 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
|
||||
{
|
||||
response["created"] = Value::from(created_at);
|
||||
}
|
||||
if let Some(service_tier) = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(|value| value.get("service_tier"))
|
||||
.cloned()
|
||||
{
|
||||
if let Some(service_tier) = openai_service_tier_extension(&canonical.extensions).cloned() {
|
||||
response["service_tier"] = service_tier;
|
||||
}
|
||||
response
|
||||
|
||||
@@ -10,6 +10,13 @@ use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
|
||||
use crate::formats::shared::stream_core::common::*;
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
fn normalize_openai_service_tier(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && value.len() <= 64)
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAIChatProviderToolState {
|
||||
id: Option<String>,
|
||||
@@ -21,6 +28,7 @@ struct OpenAIChatProviderToolState {
|
||||
pub struct OpenAIChatProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
actual_service_tier: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
pending_finish_reason: Option<String>,
|
||||
@@ -46,6 +54,7 @@ struct OpenAIResponsesProviderToolResultState {
|
||||
pub struct OpenAIResponsesProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
actual_service_tier: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text_parts: BTreeMap<String, String>,
|
||||
@@ -60,6 +69,10 @@ pub struct OpenAIResponsesProviderState {
|
||||
}
|
||||
|
||||
impl OpenAIChatProviderState {
|
||||
pub(crate) fn actual_service_tier(&self) -> Option<&str> {
|
||||
self.actual_service_tier.as_deref()
|
||||
}
|
||||
|
||||
fn finish_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage_object = value?.as_object()?;
|
||||
let has_token_fields = [
|
||||
@@ -129,6 +142,11 @@ impl OpenAIChatProviderState {
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.model.clone());
|
||||
if let Some(service_tier) =
|
||||
normalize_openai_service_tier(chunk_object.get("service_tier").and_then(Value::as_str))
|
||||
{
|
||||
self.actual_service_tier = Some(service_tier);
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
|
||||
@@ -369,6 +387,10 @@ impl OpenAIChatProviderState {
|
||||
}
|
||||
|
||||
impl OpenAIResponsesProviderState {
|
||||
pub(crate) fn actual_service_tier(&self) -> Option<&str> {
|
||||
self.actual_service_tier.as_deref()
|
||||
}
|
||||
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
@@ -1229,6 +1251,11 @@ impl OpenAIResponsesProviderState {
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.model.clone());
|
||||
if let Some(service_tier) =
|
||||
normalize_openai_service_tier(response.get("service_tier").and_then(Value::as_str))
|
||||
{
|
||||
self.actual_service_tier = Some(service_tier);
|
||||
}
|
||||
}
|
||||
|
||||
match value
|
||||
@@ -1781,6 +1808,7 @@ impl OpenAIResponsesProviderState {
|
||||
pub struct OpenAIChatClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
actual_service_tier: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
next_tool_call_index: usize,
|
||||
@@ -1826,6 +1854,7 @@ fn web_search_query_from_arguments(arguments: &str) -> String {
|
||||
pub struct OpenAIResponsesClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
actual_service_tier: Option<String>,
|
||||
created_at: Option<i64>,
|
||||
message_item_id: Option<String>,
|
||||
reasoning_item_id: Option<String>,
|
||||
@@ -1851,6 +1880,31 @@ pub struct OpenAIResponsesClientEmitter {
|
||||
}
|
||||
|
||||
impl OpenAIChatClientEmitter {
|
||||
pub(crate) fn set_actual_service_tier(&mut self, value: Option<&str>) {
|
||||
if value.is_some_and(|value| {
|
||||
self.actual_service_tier
|
||||
.as_deref()
|
||||
.is_some_and(|current| current.eq_ignore_ascii_case(value.trim()))
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
if let Some(value) = normalize_openai_service_tier(value) {
|
||||
self.actual_service_tier = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_chunk(&self, mut chunk: Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if let (Some(service_tier), Some(object)) =
|
||||
(self.actual_service_tier.as_ref(), chunk.as_object_mut())
|
||||
{
|
||||
object.insert(
|
||||
"service_tier".to_string(),
|
||||
Value::String(service_tier.clone()),
|
||||
);
|
||||
}
|
||||
encode_json_sse(None, &chunk)
|
||||
}
|
||||
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.response_id = Some(frame.id.clone());
|
||||
self.model = Some(frame.model.clone());
|
||||
@@ -1861,15 +1915,12 @@ impl OpenAIChatClientEmitter {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_role_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
),
|
||||
)
|
||||
self.encode_chunk(build_openai_chat_role_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
))
|
||||
}
|
||||
|
||||
fn chat_tool_call_index(&mut self, canonical_index: usize) -> usize {
|
||||
@@ -1889,9 +1940,8 @@ impl OpenAIChatClientEmitter {
|
||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
out.extend(
|
||||
self.encode_chunk(build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
@@ -1899,61 +1949,54 @@ impl OpenAIChatClientEmitter {
|
||||
text,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
))?,
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ReasoningDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"reasoning_content": text,
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}),
|
||||
)?);
|
||||
out.extend(self.encode_chunk(json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"reasoning_content": text,
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}))?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ReasoningSummaryDone => {
|
||||
// CPA strategy: emit "\n\n" as paragraph separator between
|
||||
// reasoning sections, matching CPA's Chat downstream behavior.
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"reasoning_content": "\n\n",
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}),
|
||||
)?);
|
||||
out.extend(self.encode_chunk(json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"reasoning_content": "\n\n",
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}))?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ReasoningSignature(_) => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::ContentPart(part) => {
|
||||
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(
|
||||
out.extend(
|
||||
self.encode_chunk(build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
@@ -1961,8 +2004,8 @@ impl OpenAIChatClientEmitter {
|
||||
placeholder,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
))?,
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ImageGenerationCall { item, .. } => {
|
||||
@@ -1971,9 +2014,8 @@ impl OpenAIChatClientEmitter {
|
||||
};
|
||||
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(
|
||||
out.extend(
|
||||
self.encode_chunk(build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
@@ -1981,8 +2023,8 @@ impl OpenAIChatClientEmitter {
|
||||
placeholder,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
))?,
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::OpenAiResponsesOutputItem { .. } => {
|
||||
@@ -1997,9 +2039,8 @@ impl OpenAIChatClientEmitter {
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let chat_index = self.chat_tool_call_index(index);
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
out.extend(
|
||||
self.encode_chunk(build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
@@ -2015,35 +2056,32 @@ impl OpenAIChatClientEmitter {
|
||||
}
|
||||
})]),
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
))?,
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let chat_index = self.chat_tool_call_index(index);
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": chat_index,
|
||||
"function": {
|
||||
"arguments": arguments,
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}),
|
||||
)?);
|
||||
out.extend(self.encode_chunk(json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": chat_index,
|
||||
"function": {
|
||||
"arguments": arguments,
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}))?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolResultDelta {
|
||||
@@ -2060,21 +2098,18 @@ impl OpenAIChatClientEmitter {
|
||||
delta.insert("name".to_string(), Value::String(name));
|
||||
}
|
||||
delta.insert("content".to_string(), Value::String(content));
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": Value::Object(delta),
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}),
|
||||
)?);
|
||||
out.extend(self.encode_chunk(json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": Value::Object(delta),
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}))?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(payload)
|
||||
@@ -2093,27 +2128,25 @@ impl OpenAIChatClientEmitter {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_finish_chunk(
|
||||
out.extend(
|
||||
self.encode_chunk(build_openai_chat_finish_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
finish_reason.as_deref(),
|
||||
),
|
||||
)?);
|
||||
))?,
|
||||
);
|
||||
if let Some(usage) = usage {
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_usage_chunk_from_usage(
|
||||
out.extend(
|
||||
self.encode_chunk(build_openai_chat_usage_chunk_from_usage(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
&usage,
|
||||
),
|
||||
)?);
|
||||
))?,
|
||||
);
|
||||
}
|
||||
out.extend(encode_done_sse());
|
||||
self.finished = true;
|
||||
@@ -2126,16 +2159,13 @@ impl OpenAIChatClientEmitter {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let out = encode_json_sse(
|
||||
let out = self.encode_chunk(build_openai_chat_finish_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
None,
|
||||
&build_openai_chat_finish_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
))?;
|
||||
self.finished = true;
|
||||
let mut bytes = out;
|
||||
bytes.extend(encode_done_sse());
|
||||
@@ -2144,6 +2174,19 @@ impl OpenAIChatClientEmitter {
|
||||
}
|
||||
|
||||
impl OpenAIResponsesClientEmitter {
|
||||
pub(crate) fn set_actual_service_tier(&mut self, value: Option<&str>) {
|
||||
if value.is_some_and(|value| {
|
||||
self.actual_service_tier
|
||||
.as_deref()
|
||||
.is_some_and(|current| current.eq_ignore_ascii_case(value.trim()))
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
if let Some(value) = normalize_openai_service_tier(value) {
|
||||
self.actual_service_tier = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn response_id(&self) -> &str {
|
||||
self.response_id.as_deref().unwrap_or("resp-local-stream")
|
||||
}
|
||||
@@ -2191,6 +2234,14 @@ impl OpenAIResponsesClientEmitter {
|
||||
{
|
||||
response_object.insert("created_at".to_string(), Value::from(created_at));
|
||||
}
|
||||
if let (Some(service_tier), Some(response_object)) =
|
||||
(self.actual_service_tier.as_ref(), response.as_object_mut())
|
||||
{
|
||||
response_object.insert(
|
||||
"service_tier".to_string(),
|
||||
Value::String(service_tier.clone()),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -2815,6 +2866,12 @@ impl OpenAIResponsesClientEmitter {
|
||||
response_object.insert("created_at".to_string(), Value::from(created_at));
|
||||
}
|
||||
ensure_modern_openai_responses_response_fields(response_object);
|
||||
if let Some(service_tier) = self.actual_service_tier.as_ref() {
|
||||
response_object.insert(
|
||||
"service_tier".to_string(),
|
||||
Value::String(service_tier.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -2848,6 +2905,7 @@ impl OpenAIResponsesClientEmitter {
|
||||
"authoritative OpenAI Responses terminal payload must be an object",
|
||||
)
|
||||
})?;
|
||||
self.set_actual_service_tier(response_object.get("service_tier").and_then(Value::as_str));
|
||||
if let Some(response_id) = response_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(crate) fn validate_openai_reasoning_request_with_source_model(
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ pub(crate) fn validate_openai_reasoning_request_with_model_profile(
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
body: &Value,
|
||||
use_model_card_reasoning_contract: bool,
|
||||
model_card_reasoning_efforts: Option<&[String]>,
|
||||
supports_reasoning_mode: Option<bool>,
|
||||
) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let Some(object) = body.as_object() else {
|
||||
@@ -106,7 +106,7 @@ pub(crate) fn validate_openai_reasoning_request_with_model_profile(
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
use_model_card_reasoning_contract,
|
||||
model_card_reasoning_efforts,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,12 @@ pub(crate) fn validate_openai_reasoning_request_with_model_profile(
|
||||
{
|
||||
validate_reasoning_context(context)?;
|
||||
}
|
||||
if let Some(summary) = reasoning
|
||||
.and_then(|reasoning| reasoning.get("summary"))
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
validate_reasoning_summary(summary)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -132,7 +138,7 @@ fn validate_reasoning_effort(
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
use_model_card_reasoning_contract: bool,
|
||||
model_card_reasoning_efforts: Option<&[String]>,
|
||||
) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let field = if source_api_format == "openai:chat" {
|
||||
"reasoning_effort"
|
||||
@@ -163,8 +169,22 @@ fn validate_reasoning_effort(
|
||||
reason: "ultra is a Codex client preset, not an OpenAI wire effort".to_string(),
|
||||
});
|
||||
}
|
||||
if use_model_card_reasoning_contract {
|
||||
return Ok(());
|
||||
if let Some(supported_efforts) =
|
||||
model_card_reasoning_efforts.filter(|values| !values.is_empty())
|
||||
{
|
||||
if supported_efforts
|
||||
.iter()
|
||||
.any(|effort| effort == raw.trim() || (raw.trim() == "max" && effort == "ultra"))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::UnsupportedForModel,
|
||||
field: field.to_string(),
|
||||
value: Some(raw.to_string()),
|
||||
reason: "provider model card does not support the requested reasoning effort"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let Some(effort) = ReasoningEffort::parse(raw) else {
|
||||
return Ok(());
|
||||
@@ -252,6 +272,26 @@ fn validate_reasoning_context(value: &Value) -> Result<(), OpenAiReasoningContra
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_reasoning_summary(value: &Value) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let Some(summary) = value.as_str() else {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidType,
|
||||
field: "reasoning.summary".to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: "reasoning summary must be a string".to_string(),
|
||||
});
|
||||
};
|
||||
if matches!(summary, "auto" | "concise" | "detailed") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidEnum,
|
||||
field: "reasoning.summary".to_string(),
|
||||
value: Some(summary.to_string()),
|
||||
reason: "reasoning summary is not a supported wire value".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -406,6 +446,33 @@ mod tests {
|
||||
assert_eq!(invalid.kind, OpenAiReasoningViolationKind::InvalidEnum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_accepts_only_openai_wire_values() {
|
||||
for summary in ["auto", "concise", "detailed"] {
|
||||
validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"reasoning": {"summary": summary}}),
|
||||
)
|
||||
.expect("documented reasoning summary should be accepted");
|
||||
}
|
||||
|
||||
for (summary, expected_kind) in [
|
||||
(json!("none"), OpenAiReasoningViolationKind::InvalidEnum),
|
||||
(json!(true), OpenAiReasoningViolationKind::InvalidType),
|
||||
] {
|
||||
let error = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"reasoning": {"summary": summary}}),
|
||||
)
|
||||
.expect_err("invalid reasoning summary should be rejected");
|
||||
assert_eq!(error.kind, expected_kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_reasoning_fields_are_treated_as_unconfigured() {
|
||||
for body in [
|
||||
|
||||
@@ -146,7 +146,7 @@ fn validate_openai_provider_request_contract_with_codex_model_capabilities(
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
model_capabilities.is_some(),
|
||||
model_capabilities.map(|capabilities| capabilities.supported_reasoning_efforts.as_slice()),
|
||||
None,
|
||||
)
|
||||
.map_err(OpenAiProviderRequestContractViolation::Reasoning)
|
||||
@@ -212,6 +212,40 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_finalization_enforces_model_card_reasoning_efforts() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "minimal"}
|
||||
});
|
||||
|
||||
let error = finalize_openai_provider_request(
|
||||
&mut body,
|
||||
OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: false,
|
||||
require_body_stream_field: true,
|
||||
},
|
||||
)
|
||||
.expect_err("GPT-5.6 Codex model card should reject minimal");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(
|
||||
super::OpenAiReasoningContractViolation {
|
||||
kind: crate::formats::openai::reasoning::OpenAiReasoningViolationKind::UnsupportedForModel,
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalization_reapplies_codex_and_compact_projection_after_mutations() {
|
||||
let mut body = json!({
|
||||
@@ -419,26 +453,32 @@ mod tests {
|
||||
"input": [],
|
||||
"reasoning": {"effort": "vendoreffortx"}
|
||||
});
|
||||
finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
let error = finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut custom,
|
||||
finalization,
|
||||
Some(&capabilities),
|
||||
)
|
||||
.expect("Codex custom reasoning efforts should remain exact");
|
||||
assert_eq!(custom["reasoning"]["effort"], "vendoreffortx");
|
||||
.expect_err("custom reasoning efforts should match the model card exactly");
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(_)
|
||||
));
|
||||
|
||||
let mut ultra = json!({
|
||||
"model": "codex-custom",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "ultra"}
|
||||
});
|
||||
finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
let error = finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut ultra,
|
||||
finalization,
|
||||
Some(&capabilities),
|
||||
)
|
||||
.expect("ultra should use max on the OpenAI wire contract");
|
||||
assert_eq!(ultra["reasoning"]["effort"], "max");
|
||||
.expect_err("ultra should require model-card support before mapping to max");
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -626,7 +666,7 @@ mod tests {
|
||||
"stream_options": {"include_usage": true},
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "future"},
|
||||
"reasoning": {"effort": "max"},
|
||||
"text": {"verbosity": "medium"},
|
||||
"tools": [{"type": "function", "name": "lookup", "parameters": {}}]
|
||||
});
|
||||
@@ -656,7 +696,7 @@ mod tests {
|
||||
assert!(body.get(field).is_none(), "{field} must not reach Compact");
|
||||
}
|
||||
assert_eq!(body["parallel_tool_calls"], false);
|
||||
assert_eq!(body["reasoning"]["effort"], "future");
|
||||
assert_eq!(body["reasoning"]["effort"], "max");
|
||||
assert_eq!(body["reasoning"]["context"], "all_turns");
|
||||
assert_eq!(body["text"]["verbosity"], "medium");
|
||||
assert!(body.get("tools").is_none());
|
||||
|
||||
@@ -1118,7 +1118,11 @@ fn ensure_codex_reasoning_defaults(
|
||||
.get_mut("reasoning")
|
||||
.and_then(Value::as_object_mut)
|
||||
.and_then(|reasoning| {
|
||||
if !capabilities.supports_reasoning_summary_parameter {
|
||||
if !capabilities.supports_reasoning_summary_parameter
|
||||
|| reasoning
|
||||
.get("summary")
|
||||
.is_some_and(codex_reasoning_summary_is_disabled)
|
||||
{
|
||||
reasoning.remove("summary");
|
||||
}
|
||||
reasoning.get("summary")
|
||||
@@ -1154,7 +1158,9 @@ fn ensure_codex_reasoning_defaults(
|
||||
}
|
||||
}
|
||||
if !capabilities.supports_reasoning_summary_parameter
|
||||
|| reasoning_object.get("summary").is_some_and(Value::is_null)
|
||||
|| reasoning_object
|
||||
.get("summary")
|
||||
.is_some_and(codex_reasoning_summary_is_disabled)
|
||||
{
|
||||
reasoning_object.remove("summary");
|
||||
} else if !reasoning_object.contains_key("summary") {
|
||||
@@ -1173,6 +1179,13 @@ fn ensure_codex_reasoning_defaults(
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_reasoning_summary_is_disabled(value: &Value) -> bool {
|
||||
value.is_null()
|
||||
|| value
|
||||
.as_str()
|
||||
.is_some_and(|summary| summary.eq_ignore_ascii_case("none"))
|
||||
}
|
||||
|
||||
fn remove_codex_reasoning_summary_delivery(body_object: &mut serde_json::Map<String, Value>) {
|
||||
let remove_stream_options = body_object
|
||||
.get_mut("stream_options")
|
||||
@@ -2365,6 +2378,34 @@ mod tests {
|
||||
assert!(provider_request_body.get("instructions").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_body_edits_omit_disabled_reasoning_summary_and_delivery() {
|
||||
let mut provider_request_body = json!({
|
||||
"input": [{"role": "user", "content": "hello"}],
|
||||
"model": "gpt-5.6-sol",
|
||||
"stream": true,
|
||||
"reasoning": {"effort": "high", "summary": "none"},
|
||||
"stream_options": {
|
||||
"reasoning_summary_delivery": "sequential_cutoff",
|
||||
"future_option": true
|
||||
}
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(provider_request_body["reasoning"].get("summary").is_none());
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"],
|
||||
json!({"future_option": true})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_body_edits_project_include_and_preserve_disabled_parallel_calls() {
|
||||
let mut provider_request_body = json!( {
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::{
|
||||
flush_openai_responses_message_item, is_openai_responses_raw_block,
|
||||
is_openai_thinking_block, namespace_extension_object, openai_responses_extensions,
|
||||
openai_responses_item_extension_object, openai_responses_output_to_canonical,
|
||||
openai_responses_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
|
||||
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
|
||||
openai_responses_usage_to_canonical, openai_service_tier_extension, CanonicalContentBlock,
|
||||
CanonicalResponse, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
@@ -371,9 +371,9 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
|
||||
response.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(service_tier) = request_object.get("service_tier").cloned() {
|
||||
response.insert("service_tier".to_string(), service_tier);
|
||||
}
|
||||
}
|
||||
if let Some(service_tier) = openai_service_tier_extension(&canonical.extensions).cloned() {
|
||||
response.insert("service_tier".to_string(), service_tier);
|
||||
}
|
||||
let mut extension_fields = namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
pub struct StreamingStandardFormatMatrix {
|
||||
provider: Option<ProviderStreamParser>,
|
||||
client: Option<ClientStreamEmitter>,
|
||||
propagated_actual_service_tier: Option<String>,
|
||||
terminated: bool,
|
||||
}
|
||||
|
||||
@@ -40,10 +41,21 @@ impl StreamingStandardFormatMatrix {
|
||||
self.terminated = true;
|
||||
return self.emit_error(error_body);
|
||||
}
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
let (provider, client, propagated_actual_service_tier) = (
|
||||
&mut self.provider,
|
||||
&mut self.client,
|
||||
&mut self.propagated_actual_service_tier,
|
||||
);
|
||||
let Some(provider) = provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.push_line(report_context, line)?;
|
||||
if provider.actual_service_tier() != propagated_actual_service_tier.as_deref() {
|
||||
*propagated_actual_service_tier = provider.actual_service_tier().map(ToOwned::to_owned);
|
||||
if let Some(client) = client.as_mut() {
|
||||
client.set_actual_service_tier(propagated_actual_service_tier.as_deref());
|
||||
}
|
||||
}
|
||||
self.emit_frames(frames)
|
||||
}
|
||||
|
||||
@@ -52,10 +64,21 @@ impl StreamingStandardFormatMatrix {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
let (provider, client, propagated_actual_service_tier) = (
|
||||
&mut self.provider,
|
||||
&mut self.client,
|
||||
&mut self.propagated_actual_service_tier,
|
||||
);
|
||||
let Some(provider) = provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.finish(report_context)?;
|
||||
if provider.actual_service_tier() != propagated_actual_service_tier.as_deref() {
|
||||
*propagated_actual_service_tier = provider.actual_service_tier().map(ToOwned::to_owned);
|
||||
if let Some(client) = client.as_mut() {
|
||||
client.set_actual_service_tier(propagated_actual_service_tier.as_deref());
|
||||
}
|
||||
}
|
||||
let mut out = self.emit_frames(frames)?;
|
||||
if let Some(client) = self.client.as_mut() {
|
||||
out.extend(client.finish()?);
|
||||
@@ -133,14 +156,6 @@ impl StreamingStandardTerminalObserver {
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<(), AiSurfaceFinalizeError> {
|
||||
if let Some(service_tier) = decode_json_data_line(&line)
|
||||
.as_ref()
|
||||
.and_then(provider_actual_service_tier_from_stream_event)
|
||||
{
|
||||
self.latest_summary
|
||||
.get_or_insert_with(ExecutionStreamTerminalSummary::default)
|
||||
.provider_actual_service_tier = Some(service_tier);
|
||||
}
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(());
|
||||
@@ -148,7 +163,13 @@ impl StreamingStandardTerminalObserver {
|
||||
match provider {
|
||||
TerminalStreamParser::Standard(provider) => {
|
||||
let frames = provider.push_line(report_context, line)?;
|
||||
let actual_service_tier = provider.actual_service_tier().map(ToOwned::to_owned);
|
||||
self.observe_frames(frames);
|
||||
if let Some(actual_service_tier) = actual_service_tier {
|
||||
self.latest_summary
|
||||
.get_or_insert_with(ExecutionStreamTerminalSummary::default)
|
||||
.provider_actual_service_tier = Some(actual_service_tier);
|
||||
}
|
||||
}
|
||||
TerminalStreamParser::OpenAIImage(provider) => {
|
||||
if let Some(summary) = provider.push_line(report_context, line)? {
|
||||
@@ -254,17 +275,6 @@ impl StreamingStandardTerminalObserver {
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_actual_service_tier_from_stream_event(event: &Value) -> Option<String> {
|
||||
event
|
||||
.get("response")
|
||||
.and_then(|response| response.get("service_tier"))
|
||||
.or_else(|| event.get("service_tier"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && value.len() <= 64)
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
enum TerminalStreamParser {
|
||||
Standard(ProviderStreamParser),
|
||||
OpenAIImage(OpenAiImageStreamTerminalState),
|
||||
@@ -333,6 +343,14 @@ impl ProviderStreamParser {
|
||||
ProviderStreamParser::Gemini(state) => state.finish(report_context),
|
||||
}
|
||||
}
|
||||
|
||||
fn actual_service_tier(&self) -> Option<&str> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(state) => state.actual_service_tier(),
|
||||
ProviderStreamParser::OpenAIResponses(state) => state.actual_service_tier(),
|
||||
ProviderStreamParser::Claude(_) | ProviderStreamParser::Gemini(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientStreamEmitter {
|
||||
@@ -410,6 +428,14 @@ impl ClientStreamEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_actual_service_tier(&mut self, value: Option<&str>) {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.set_actual_service_tier(value),
|
||||
ClientStreamEmitter::OpenAIResponses(state) => state.set_actual_service_tier(value),
|
||||
ClientStreamEmitter::Claude(_) | ClientStreamEmitter::Gemini(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
|
||||
@@ -604,6 +630,15 @@ mod tests {
|
||||
format!("data: {}\n", value).into_bytes()
|
||||
}
|
||||
|
||||
fn json_data_events(bytes: &[u8]) -> Vec<Value> {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data: "))
|
||||
.filter(|payload| *payload != "[DONE]")
|
||||
.filter_map(|payload| serde_json::from_str(payload).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_provider_errors_to_openai_chat_error_bodies() {
|
||||
let cases = [
|
||||
@@ -1895,6 +1930,7 @@ mod tests {
|
||||
"model": "gpt-5.6",
|
||||
"service_tier": "Default",
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
|
||||
})),
|
||||
)
|
||||
.expect("Chat terminal tier should be observed");
|
||||
@@ -1904,6 +1940,17 @@ mod tests {
|
||||
.and_then(|summary| summary.provider_actual_service_tier.as_deref()),
|
||||
Some("default")
|
||||
);
|
||||
let chat_summary = chat_observer
|
||||
.latest_summary()
|
||||
.expect("Chat summary should exist");
|
||||
assert!(chat_summary.observed_finish);
|
||||
assert_eq!(
|
||||
chat_summary
|
||||
.standardized_usage
|
||||
.as_ref()
|
||||
.map(|usage| (usage.input_tokens, usage.output_tokens)),
|
||||
Some((10, 2))
|
||||
);
|
||||
|
||||
let responses_context = report_context("openai:responses", "openai:responses");
|
||||
let mut responses_observer = StreamingStandardTerminalObserver::default();
|
||||
@@ -1918,6 +1965,7 @@ mod tests {
|
||||
"status": "completed",
|
||||
"service_tier": "Flex",
|
||||
"output": [],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12},
|
||||
},
|
||||
"sequence_number": 1,
|
||||
})),
|
||||
@@ -1929,6 +1977,85 @@ mod tests {
|
||||
.and_then(|summary| summary.provider_actual_service_tier.as_deref()),
|
||||
Some("flex")
|
||||
);
|
||||
let responses_summary = responses_observer
|
||||
.latest_summary()
|
||||
.expect("Responses summary should exist");
|
||||
assert!(responses_summary.observed_finish);
|
||||
assert_eq!(
|
||||
responses_summary
|
||||
.standardized_usage
|
||||
.as_ref()
|
||||
.map(|usage| (usage.input_tokens, usage.output_tokens)),
|
||||
Some((10, 2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_client_chunks_carry_provider_actual_service_tier() {
|
||||
let context = report_context("openai:chat", "openai:chat");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"id": "chatcmpl_tier_stream",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": "gpt-5.6",
|
||||
"service_tier": "Default",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": "done"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}
|
||||
})),
|
||||
)
|
||||
.expect("Chat stream should transform");
|
||||
let events = json_data_events(&output);
|
||||
|
||||
assert!(!events.is_empty());
|
||||
assert!(events
|
||||
.iter()
|
||||
.all(|event| event.get("service_tier") == Some(&json!("default"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_actual_service_tier_reaches_transformed_chat_chunks() {
|
||||
let context = report_context("openai:responses", "openai:chat");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_tier_stream",
|
||||
"object": "response",
|
||||
"model": "gpt-5.6",
|
||||
"status": "completed",
|
||||
"service_tier": "Flex",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_tier_stream",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "done",
|
||||
"annotations": []
|
||||
}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("Responses stream should transform");
|
||||
let events = json_data_events(&output);
|
||||
|
||||
assert!(!events.is_empty());
|
||||
assert!(events
|
||||
.iter()
|
||||
.all(|event| event.get("service_tier") == Some(&json!("flex"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -93,14 +93,33 @@ pub fn maybe_bridge_standard_sync_json_to_stream(
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let terminal_summary =
|
||||
build_terminal_summary_from_openai_responses_response(&openai_responses_response);
|
||||
let provider_actual_service_tier = provider_actual_service_tier_from_sync_response(
|
||||
provider_body_json,
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
let terminal_summary = build_terminal_summary_from_openai_responses_response(
|
||||
&openai_responses_response,
|
||||
provider_actual_service_tier.clone(),
|
||||
);
|
||||
let canonical_frames = build_canonical_frames_from_openai_responses_response(
|
||||
&openai_responses_response,
|
||||
&bridge_context,
|
||||
)?;
|
||||
let sse_body =
|
||||
emit_client_stream_from_canonical_frames(canonical_frames, client_api_format.as_str())?;
|
||||
let sse_body = if is_openai_responses_family_api_format(client_api_format.as_str()) {
|
||||
emit_openai_responses_stream_with_authoritative_terminal(
|
||||
canonical_frames,
|
||||
&openai_responses_response,
|
||||
openai_responses_terminal_event_type(&openai_responses_response)
|
||||
.unwrap_or("response.completed"),
|
||||
provider_actual_service_tier.as_deref(),
|
||||
)?
|
||||
} else {
|
||||
emit_client_stream_from_canonical_frames(
|
||||
canonical_frames,
|
||||
client_api_format.as_str(),
|
||||
provider_actual_service_tier.as_deref(),
|
||||
)?
|
||||
};
|
||||
|
||||
Ok(Some(SyncToStreamBridgeOutcome {
|
||||
sse_body,
|
||||
@@ -140,11 +159,15 @@ fn bridge_openai_responses_same_family_sync_json_to_stream(
|
||||
canonical_frames,
|
||||
response,
|
||||
terminal_event_type,
|
||||
provider_actual_service_tier_from_sync_response(response, provider_api_format).as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(Some(SyncToStreamBridgeOutcome {
|
||||
sse_body,
|
||||
terminal_summary: build_terminal_summary_from_openai_responses_response(response),
|
||||
terminal_summary: build_terminal_summary_from_openai_responses_response(
|
||||
response,
|
||||
provider_actual_service_tier_from_sync_response(response, provider_api_format),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1010,14 +1033,17 @@ fn openai_responses_terminal_event_type(response: &Value) -> Option<&'static str
|
||||
fn emit_client_stream_from_canonical_frames(
|
||||
canonical_frames: Vec<CanonicalStreamFrame>,
|
||||
client_api_format: &str,
|
||||
provider_actual_service_tier: Option<&str>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match client_api_format {
|
||||
"openai:chat" => {
|
||||
let mut emitter = OpenAIChatClientEmitter::default();
|
||||
emitter.set_actual_service_tier(provider_actual_service_tier);
|
||||
emit_with_openai_chat_emitter(&mut emitter, canonical_frames)
|
||||
}
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
emitter.set_actual_service_tier(provider_actual_service_tier);
|
||||
emit_with_openai_responses_emitter(&mut emitter, canonical_frames)
|
||||
}
|
||||
"claude:messages" => {
|
||||
@@ -1076,8 +1102,10 @@ fn emit_openai_responses_stream_with_authoritative_terminal(
|
||||
canonical_frames: Vec<CanonicalStreamFrame>,
|
||||
authoritative_response: &Value,
|
||||
terminal_event_type: &'static str,
|
||||
provider_actual_service_tier: Option<&str>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
emitter.set_actual_service_tier(provider_actual_service_tier);
|
||||
let mut output = Vec::new();
|
||||
for frame in canonical_frames {
|
||||
if matches!(
|
||||
@@ -1145,6 +1173,7 @@ fn emit_with_gemini_emitter(
|
||||
|
||||
fn build_terminal_summary_from_openai_responses_response(
|
||||
openai_responses_response: &Value,
|
||||
provider_actual_service_tier: Option<String>,
|
||||
) -> Option<ExecutionStreamTerminalSummary> {
|
||||
let response = openai_responses_response.as_object()?;
|
||||
let response_id = response
|
||||
@@ -1168,18 +1197,34 @@ fn build_terminal_summary_from_openai_responses_response(
|
||||
finish_reason,
|
||||
response_id,
|
||||
model,
|
||||
provider_actual_service_tier: response
|
||||
.get("service_tier")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && value.len() <= 64)
|
||||
.map(str::to_ascii_lowercase),
|
||||
provider_actual_service_tier,
|
||||
observed_finish: true,
|
||||
unknown_event_count: 0,
|
||||
parser_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_actual_service_tier_from_sync_response(
|
||||
provider_response: &Value,
|
||||
provider_api_format: &str,
|
||||
) -> Option<String> {
|
||||
if !matches!(
|
||||
normalize_api_format(provider_api_format).as_str(),
|
||||
"openai:chat" | "openai:responses" | "openai:responses:compact"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
provider_response
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|response| response.get("service_tier"))
|
||||
.or_else(|| provider_response.get("service_tier"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && value.len() <= 64)
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
fn resolve_openai_responses_finish_reason(output: &[Value]) -> String {
|
||||
let has_tool_calls = output.iter().filter_map(Value::as_object).any(|item| {
|
||||
item.get("type")
|
||||
@@ -1367,6 +1412,112 @@ mod tests {
|
||||
assert_eq!(event["response"], response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_bridge_keeps_provider_actual_service_tier_authoritative() {
|
||||
let provider_response = json!({
|
||||
"id": "chatcmpl_actual_tier",
|
||||
"object": "chat.completion",
|
||||
"model": "gpt-5.6-sol",
|
||||
"service_tier": "Default",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "done"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}
|
||||
});
|
||||
let report_context = json!({
|
||||
"original_request_body": {
|
||||
"model": "gpt-5.6-sol",
|
||||
"service_tier": "priority"
|
||||
}
|
||||
});
|
||||
|
||||
let outcome = maybe_bridge_standard_sync_json_to_stream(
|
||||
&provider_response,
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
Some(&report_context),
|
||||
)
|
||||
.expect("cross-format bridge should succeed")
|
||||
.expect("cross-format bridge should emit terminal SSE");
|
||||
|
||||
assert_eq!(
|
||||
outcome
|
||||
.terminal_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.provider_actual_service_tier.as_deref()),
|
||||
Some("default")
|
||||
);
|
||||
let events = json_sse_events(&utf8(outcome.sse_body));
|
||||
let terminal = events
|
||||
.iter()
|
||||
.find(|event| event["type"] == "response.completed")
|
||||
.expect("terminal event should exist");
|
||||
assert_eq!(terminal["response"]["service_tier"], "Default");
|
||||
let response_events = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
matches!(
|
||||
event.get("type").and_then(Value::as_str),
|
||||
Some("response.created" | "response.in_progress" | "response.completed")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!response_events.is_empty());
|
||||
assert!(response_events.iter().all(|event| {
|
||||
event
|
||||
.pointer("/response/service_tier")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tier| tier.eq_ignore_ascii_case("default"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_bridge_does_not_echo_requested_service_tier_as_provider_actual() {
|
||||
let provider_response = json!({
|
||||
"id": "chatcmpl_without_actual_tier",
|
||||
"object": "chat.completion",
|
||||
"model": "gpt-5.6-sol",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "done"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}
|
||||
});
|
||||
let report_context = json!({
|
||||
"original_request_body": {
|
||||
"model": "gpt-5.6-sol",
|
||||
"service_tier": "priority"
|
||||
}
|
||||
});
|
||||
|
||||
let outcome = maybe_bridge_standard_sync_json_to_stream(
|
||||
&provider_response,
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
Some(&report_context),
|
||||
)
|
||||
.expect("cross-format bridge should succeed")
|
||||
.expect("cross-format bridge should emit terminal SSE");
|
||||
|
||||
assert_eq!(
|
||||
outcome
|
||||
.terminal_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.provider_actual_service_tier.as_deref()),
|
||||
None
|
||||
);
|
||||
let events = json_sse_events(&utf8(outcome.sse_body));
|
||||
assert!(events.iter().all(|event| {
|
||||
event
|
||||
.get("response")
|
||||
.and_then(|response| response.get("service_tier"))
|
||||
.is_none()
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridges_output_only_compact_json_without_reshaping_the_terminal_response() {
|
||||
let response = json!({
|
||||
|
||||
@@ -6577,6 +6577,23 @@ pub(crate) fn openai_responses_extension(extensions: &BTreeMap<String, Value>) -
|
||||
.or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE))
|
||||
}
|
||||
|
||||
pub(crate) fn openai_service_tier_extension(
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
) -> Option<&Value> {
|
||||
[
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
"openai",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|namespace| {
|
||||
extensions
|
||||
.get(namespace)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| object.get("service_tier"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn openai_responses_item_extension_object(
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
existing: &Map<String, Value>,
|
||||
|
||||
Reference in New Issue
Block a user