mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f905e4b3fb | ||
|
|
ed3e23887b | ||
|
|
91f35d88cf |
@@ -213,13 +213,17 @@ fn capitalize_first(value: &str) -> String {
|
||||
|
||||
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
|
||||
match billing? {
|
||||
domain::UsageBilling::Total { total, .. } => Some(BillingView {
|
||||
domain::UsageBilling::Total { source, total } => Some(BillingView {
|
||||
input_amount_display: "—".to_owned(),
|
||||
output_amount_display: "—".to_owned(),
|
||||
cache_read_amount_display: "—".to_owned(),
|
||||
cache_write_amount_display: "—".to_owned(),
|
||||
standard_amount_display: "—".to_owned(),
|
||||
total_amount_display: format_money(total),
|
||||
total_amount_display: if source == "calculated" {
|
||||
format!("≈ {}", format_money(total))
|
||||
} else {
|
||||
format_money(total)
|
||||
},
|
||||
input_price_display: "—".to_owned(),
|
||||
output_price_display: "—".to_owned(),
|
||||
cache_read_price_display: "—".to_owned(),
|
||||
|
||||
@@ -919,6 +919,22 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
compact: true,
|
||||
started_at: Utc::now(),
|
||||
});
|
||||
{
|
||||
let mut records = fixture.usage_records.lock().expect("usage records");
|
||||
for source in ["calculated", "provider_reported"] {
|
||||
let mut image = records[0].clone();
|
||||
image.id = format!("image_{source}");
|
||||
image.endpoint = "/v1/images/generations".to_owned();
|
||||
image.provider_kind = Some("openai".to_owned());
|
||||
image.requested_model_id = Some("gpt-image-2".to_owned());
|
||||
image.upstream_model_id = None;
|
||||
image.billing = Some(UsageBilling::Total {
|
||||
source: source.to_owned(),
|
||||
total: usd("0.00696"),
|
||||
});
|
||||
records.push(image);
|
||||
}
|
||||
}
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
@@ -1016,4 +1032,12 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
})
|
||||
);
|
||||
assert!(value["data"]["items"][0].get("metadata").is_none());
|
||||
assert_eq!(
|
||||
value["data"]["items"][1]["billing"]["totalAmountDisplay"],
|
||||
"≈ $0.007"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][2]["billing"]["totalAmountDisplay"],
|
||||
"$0.007"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -524,7 +524,7 @@ impl DefaultExecutionService {
|
||||
operation: operation.kind(),
|
||||
endpoint: metadata.endpoint,
|
||||
client_transport: metadata.transport.as_str().to_owned(),
|
||||
requested_model: target.into_public_model(),
|
||||
requested_model: target.into_public_model().or(observation.requested_model),
|
||||
client_ip: metadata.client_ip,
|
||||
user_agent: metadata.user_agent,
|
||||
reasoning_effort: observation.reasoning_effort,
|
||||
|
||||
@@ -20,7 +20,9 @@ use crate::event::{EventSequenceValidator, ProviderEvent};
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::operation::Operation;
|
||||
use crate::policy::ClientApiKeyId;
|
||||
use crate::routing::{ModelCapabilities, ModelPresentation, ProviderCandidate, UpstreamModelId};
|
||||
use crate::routing::{
|
||||
ModelCapabilities, ModelPresentation, ProviderCandidate, PublicModelId, UpstreamModelId,
|
||||
};
|
||||
use crate::upstream::OpaqueUpstreamValue;
|
||||
use crate::upstream::{UpstreamSendState, UpstreamTransport};
|
||||
|
||||
@@ -416,6 +418,8 @@ pub struct ProviderRequest {
|
||||
/// Provider 对公共观测表可解释的请求语义;未知字段保持空值。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProviderRequestObservation {
|
||||
/// 独立 Provider 端点声明的请求模型,仅用于观测,不参与文本模型目录路由。
|
||||
pub requested_model: Option<PublicModelId>,
|
||||
/// 客户端原始请求中的推理强度。
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
|
||||
@@ -26,7 +26,7 @@ use gateway_core::engine::execution::{
|
||||
use gateway_core::engine::probe::{AccountProbe, AccountProbeErrorSource, AccountProbeRequest};
|
||||
use gateway_core::engine::provider::{
|
||||
Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities,
|
||||
ProviderRegistry, ProviderRequest, ProviderStream,
|
||||
ProviderRegistry, ProviderRequest, ProviderRequestObservation, ProviderStream,
|
||||
};
|
||||
use gateway_core::engine::{
|
||||
AttemptContext, AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization,
|
||||
@@ -260,7 +260,9 @@ impl Provider for LocalFailingProvider {
|
||||
}
|
||||
}
|
||||
|
||||
struct ColdFailingProvider;
|
||||
struct ColdFailingProvider {
|
||||
requested_model: Option<PublicModelId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ColdFailingProvider {
|
||||
@@ -272,6 +274,13 @@ impl Provider for ColdFailingProvider {
|
||||
ProviderCatalogGeneration::default()
|
||||
}
|
||||
|
||||
fn request_observation(&self, _: &Operation, _: &ClientApiKeyId) -> ProviderRequestObservation {
|
||||
ProviderRequestObservation {
|
||||
requested_model: self.requested_model.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_model_capabilities(
|
||||
&self,
|
||||
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
|
||||
@@ -323,12 +332,19 @@ fn successful_authentication_should_record_client_key_usage() {
|
||||
|
||||
#[test]
|
||||
fn provider_endpoint_should_persist_its_real_v1_endpoint() {
|
||||
assert_provider_endpoint_observation(None);
|
||||
assert_provider_endpoint_observation(Some("gpt-image-2"));
|
||||
}
|
||||
|
||||
fn assert_provider_endpoint_observation(model: Option<&str>) {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(client_snapshot()),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([Arc::new(ColdFailingProvider) as Arc<dyn Provider>])
|
||||
.expect("provider registry"),
|
||||
ProviderRegistry::new([Arc::new(ColdFailingProvider {
|
||||
requested_model: model.map(|model| PublicModelId::new(model).expect("model")),
|
||||
}) as Arc<dyn Provider>])
|
||||
.expect("provider registry"),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedCircuits),
|
||||
Arc::new(UnusedContinuation),
|
||||
@@ -369,7 +385,7 @@ fn provider_endpoint_should_persist_its_real_v1_endpoint() {
|
||||
vec!["/v1/images/generations".to_owned()],
|
||||
"execution error: {error:?}"
|
||||
);
|
||||
assert_eq!(store.requested_models(), vec![None]);
|
||||
assert_eq!(store.requested_models(), vec![model.map(str::to_owned)]);
|
||||
let upstream_models = store.upstream_models();
|
||||
assert!(!upstream_models.is_empty());
|
||||
assert!(upstream_models.iter().all(Option::is_none));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! OpenAI attempt 的选择、发送与响应流执行。
|
||||
|
||||
use gateway_core::metering::{CalculatedCost, Usage};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl CodexProvider {
|
||||
@@ -490,6 +492,14 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
|
||||
let response_meta =
|
||||
ResponseMeta::for_provider_endpoint(request.context.request_id().as_str());
|
||||
yield ProviderEvent::canonical(GatewayEvent::Started(response_meta.clone()));
|
||||
if matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH)
|
||||
&& let Some((usage, cost)) = image_response_metering(&request.body, &response.body)
|
||||
{
|
||||
yield ProviderEvent::canonical(GatewayEvent::Usage(usage));
|
||||
if let Some(cost) = cost {
|
||||
yield ProviderEvent::canonical(GatewayEvent::CalculatedCost(cost));
|
||||
}
|
||||
}
|
||||
let wire = ProtocolWireEvent::raw_json(PROVIDER_NAME, response.body).map_err(|_| {
|
||||
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent)
|
||||
})?;
|
||||
@@ -500,6 +510,37 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
|
||||
})
|
||||
}
|
||||
|
||||
fn image_response_metering(
|
||||
request_body: &[u8],
|
||||
body: &[u8],
|
||||
) -> Option<(Usage, Option<CalculatedCost>)> {
|
||||
// 只保留 usage,跳过通常很大的 base64 图片;原始响应仍按字节透传。
|
||||
#[derive(Deserialize)]
|
||||
struct ImageUsageEnvelope {
|
||||
usage: Option<Value>,
|
||||
}
|
||||
|
||||
let raw = serde_json::from_slice::<ImageUsageEnvelope>(body)
|
||||
.ok()?
|
||||
.usage?;
|
||||
let mut usage = Usage::new();
|
||||
usage.input_tokens = raw.get("input_tokens").and_then(Value::as_u64);
|
||||
usage.output_tokens = raw.get("output_tokens").and_then(Value::as_u64);
|
||||
usage.cached_tokens = raw
|
||||
.pointer("/input_tokens_details/cached_tokens")
|
||||
.and_then(Value::as_u64);
|
||||
usage.image_input_tokens = raw
|
||||
.pointer("/input_tokens_details/image_tokens")
|
||||
.and_then(Value::as_u64);
|
||||
usage.image_output_tokens = raw
|
||||
.pointer("/output_tokens_details/image_tokens")
|
||||
.and_then(Value::as_u64);
|
||||
// 总量是上游独立报告的事实;图片明细是总输入/输出的子集,不能再次相加。
|
||||
usage.total_tokens = raw.get("total_tokens").and_then(Value::as_u64);
|
||||
let cost = crate::transport::usage::image_calculated_cost(request_body, &raw);
|
||||
(usage != Usage::default()).then_some((usage, cost))
|
||||
}
|
||||
|
||||
pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let ColdResponse {
|
||||
client,
|
||||
|
||||
@@ -237,6 +237,15 @@ impl Provider for CodexProvider {
|
||||
_ => None,
|
||||
};
|
||||
return ProviderRequestObservation {
|
||||
requested_model: match operation {
|
||||
Operation::Search(request) => {
|
||||
observation::endpoint_requested_model(request.payload())
|
||||
}
|
||||
Operation::GenerateImage(request) => {
|
||||
observation::endpoint_requested_model(request.payload())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
continuation: ContinuationRequestObservation {
|
||||
affinity_hash: affinity.map(|affinity| affinity.persistence_hash().to_owned()),
|
||||
..Default::default()
|
||||
@@ -259,6 +268,7 @@ impl Provider for CodexProvider {
|
||||
requested: previous_response_id.is_some(),
|
||||
};
|
||||
ProviderRequestObservation {
|
||||
requested_model: None,
|
||||
reasoning_effort,
|
||||
reasoning_preset: semantics.reasoning_preset.map(str::to_owned),
|
||||
request_kind: semantics.request_kind,
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn endpoint_requested_model(
|
||||
payload: &gateway_core::operation::RawJsonPayload,
|
||||
) -> Option<gateway_core::routing::PublicModelId> {
|
||||
if payload.protocol() != PROVIDER_NAME {
|
||||
return None;
|
||||
}
|
||||
// 只读取 model,避免把编辑请求中的整张图片复制到观测数据。
|
||||
#[derive(Deserialize)]
|
||||
struct RequestModel {
|
||||
model: Option<Value>,
|
||||
}
|
||||
let model = serde_json::from_slice::<RequestModel>(payload.body())
|
||||
.ok()?
|
||||
.model?;
|
||||
gateway_core::routing::PublicModelId::new(model.as_str()?.to_owned()).ok()
|
||||
}
|
||||
|
||||
/// OpenAI Responses 的观测状态完全归 Provider 所有。
|
||||
///
|
||||
/// 每次原始 SSE/WS 事件推进状态后重新生成不可变 observation,Core 只负责携带、
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gateway_core::metering::{
|
||||
CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CurrencyCode, Decimal,
|
||||
Money,
|
||||
CalculatedCost, CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates,
|
||||
CurrencyCode, Decimal, Money,
|
||||
};
|
||||
use gateway_protocol::openai::events::{TokenUsage, retry_after_seconds_from_body};
|
||||
use reqwest::StatusCode;
|
||||
@@ -493,6 +493,76 @@ fn openai_billing_breakdown_with_context(
|
||||
))
|
||||
}
|
||||
|
||||
/// 独立 Images 端点按公开标准 API 单价估算;不是 ChatGPT 账号实际扣费。
|
||||
pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Option<CalculatedCost> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ImageModel {
|
||||
model: String,
|
||||
}
|
||||
// 只读取模型,跳过编辑请求中可能很大的 base64 图片。
|
||||
let request = serde_json::from_slice::<ImageModel>(request_body).ok()?;
|
||||
if !matches!(
|
||||
request.model.as_str(),
|
||||
"gpt-image-2" | "gpt-image-2-2026-04-21"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let input = usage.get("input_tokens")?.as_u64()?;
|
||||
let output = usage.get("output_tokens")?.as_u64()?;
|
||||
let details = usage.get("input_tokens_details")?;
|
||||
let text_input = details.get("text_tokens")?.as_u64()?;
|
||||
let image_input = details.get("image_tokens")?.as_u64()?;
|
||||
if text_input.checked_add(image_input)? != input {
|
||||
return None;
|
||||
}
|
||||
if let Some(total) = usage.get("total_tokens")
|
||||
&& total.as_u64()? != input.checked_add(output)?
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// 此模型仅输出图片;上游如报告其他输出模态,不能套用图片单价。
|
||||
if let Some(details) = usage.get("output_tokens_details")
|
||||
&& (details.get("image_tokens")?.as_u64()? != output
|
||||
|| details.get("text_tokens")?.as_u64()? != 0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let cached = match details.get("cached_tokens") {
|
||||
None => 0,
|
||||
Some(value) => value.as_u64()?,
|
||||
};
|
||||
if cached > input {
|
||||
return None;
|
||||
}
|
||||
// 缓存只给总量时,混合输入无法判定应套用哪种缓存单价。
|
||||
let (cached_text, cached_image) = match (text_input, image_input, cached) {
|
||||
(_, _, 0) => (0, 0),
|
||||
(_, 0, cached) => (cached, 0),
|
||||
(0, _, cached) => (0, cached),
|
||||
(_, _, cached) if cached == input => (text_input, image_input),
|
||||
_ => return None,
|
||||
};
|
||||
// https://developers.openai.com/api/docs/pricing (2026-09-08)
|
||||
// USD / 1M tokens: text 5 / cached 1.25; image 8 / cached 2 / output 30.
|
||||
let text = token_amounts(
|
||||
TokenRates::new(50_000, 0, 12_500),
|
||||
0,
|
||||
text_input,
|
||||
0,
|
||||
cached_text,
|
||||
0,
|
||||
)?;
|
||||
let image = token_amounts(
|
||||
TokenRates::new(80_000, 300_000, 20_000),
|
||||
0,
|
||||
image_input,
|
||||
output,
|
||||
cached_image,
|
||||
0,
|
||||
)?;
|
||||
CalculatedCost::from_usd_ticks(text.total_ticks.checked_add(image.total_ticks)?).ok()
|
||||
}
|
||||
|
||||
fn model_pricing(model: &str) -> Option<ModelPricing> {
|
||||
let normalized = normalize_model_name(model);
|
||||
if UNPRICED_MODELS
|
||||
|
||||
@@ -26,6 +26,7 @@ use gateway_core::error::{
|
||||
};
|
||||
use gateway_core::event::{GatewayEvent, WebSocketPoolKind};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::metering::Usage;
|
||||
use gateway_core::operation::{
|
||||
CapabilityRequirements, GenerateRequest, ImageRequest, ImageRequestKind, Operation,
|
||||
OperationKind, ProtocolPayload, ProviderSessionState, RawJsonPayload, StandaloneSearchRequest,
|
||||
@@ -1051,6 +1052,13 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
|
||||
let mut observed_http_json = false;
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event.expect("image provider event");
|
||||
assert!(
|
||||
event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
.all(|fact| !matches!(fact, GatewayEvent::Usage(_))),
|
||||
"missing upstream usage must remain unknown"
|
||||
);
|
||||
completed |= event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
@@ -1075,6 +1083,289 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_endpoints_should_report_usage_before_delivering_the_unchanged_body() {
|
||||
let response = br#"{ "created":1778832973,"data":[{"b64_json":"AAEC"}],"quality":"medium","size":"1024x1536","usage":{"input_tokens":1474,"input_tokens_details":{"image_tokens":1457,"text_tokens":17,"cached_tokens":100},"output_tokens":1372,"output_tokens_details":{"image_tokens":1372,"text_tokens":0},"total_tokens":2846},"future":9007199254740993 }"#;
|
||||
let expected = Usage {
|
||||
input_tokens: Some(1474),
|
||||
output_tokens: Some(1372),
|
||||
cached_tokens: Some(100),
|
||||
image_input_tokens: Some(1457),
|
||||
image_output_tokens: Some(1372),
|
||||
total_tokens: Some(2846),
|
||||
..Usage::default()
|
||||
};
|
||||
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
|
||||
let events = image_usage_events(kind, response).await;
|
||||
let usage = events
|
||||
.iter()
|
||||
.flat_map(|event| event.canonical_facts())
|
||||
.filter_map(|fact| match fact {
|
||||
GatewayEvent::Usage(usage) => Some(usage.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(usage.as_slice(), std::slice::from_ref(&expected));
|
||||
let usage_index = events
|
||||
.iter()
|
||||
.position(|event| {
|
||||
event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
.any(|fact| matches!(fact, GatewayEvent::Usage(_)))
|
||||
})
|
||||
.expect("usage event");
|
||||
let wire_index = events
|
||||
.iter()
|
||||
.position(|event| {
|
||||
event
|
||||
.wire_event()
|
||||
.and_then(|wire| wire.raw_json_body())
|
||||
.is_some()
|
||||
})
|
||||
.expect("raw image response");
|
||||
assert!(
|
||||
usage_index < wire_index,
|
||||
"usage must be observed before image delivery"
|
||||
);
|
||||
assert_eq!(
|
||||
events[wire_index]
|
||||
.wire_event()
|
||||
.and_then(|wire| wire.raw_json_body())
|
||||
.map(|body| body.as_ref()),
|
||||
Some(response.as_slice())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_usage_should_preserve_unknown_fields_and_explicit_zero_counts() {
|
||||
let cases = [
|
||||
(json!(null), None),
|
||||
(json!({}), None),
|
||||
(json!([]), None),
|
||||
(
|
||||
json!({"input_tokens": -1, "output_tokens": "42", "total_tokens": 1.5}),
|
||||
None,
|
||||
),
|
||||
(
|
||||
json!({"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "input_tokens_details": {"image_tokens": 0, "cached_tokens": 0}, "output_tokens_details": {"image_tokens": 0}}),
|
||||
Some(Usage {
|
||||
input_tokens: Some(0),
|
||||
output_tokens: Some(0),
|
||||
total_tokens: Some(0),
|
||||
cached_tokens: Some(0),
|
||||
image_input_tokens: Some(0),
|
||||
image_output_tokens: Some(0),
|
||||
..Usage::default()
|
||||
}),
|
||||
),
|
||||
(
|
||||
json!({"input_tokens": 17, "output_tokens": 1372, "input_tokens_details": {"image_tokens": "invalid"}, "output_tokens_details": {"image_tokens": 1372}}),
|
||||
Some(Usage {
|
||||
input_tokens: Some(17),
|
||||
output_tokens: Some(1372),
|
||||
image_output_tokens: Some(1372),
|
||||
..Usage::default()
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (usage, expected) in cases {
|
||||
let response = serde_json::to_vec(
|
||||
&json!({"created":1778832973,"data":[{"b64_json":"AAEC"}],"usage":usage}),
|
||||
)
|
||||
.expect("image response JSON");
|
||||
let events = image_usage_events(ImageRequestKind::Generation, &response).await;
|
||||
let actual = events
|
||||
.iter()
|
||||
.flat_map(|event| event.canonical_facts())
|
||||
.filter_map(|fact| match fact {
|
||||
GatewayEvent::Usage(usage) => Some(usage.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(actual, expected.into_iter().collect::<Vec<_>>());
|
||||
let raw = events
|
||||
.iter()
|
||||
.find_map(|event| event.wire_event().and_then(|wire| wire.raw_json_body()));
|
||||
assert_eq!(raw.map(|body| body.as_ref()), Some(response.as_slice()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_prices_should_use_modality_rates_and_precede_delivery() {
|
||||
// First case is the real gpt-image-2 response verified on 2026-09-08.
|
||||
let cases = [
|
||||
(18, 0, 229, None, 69_600_000_u128),
|
||||
(17, 1457, 1372, None, 529_010_000),
|
||||
(100, 0, 10, Some(40), 6_500_000),
|
||||
(0, 100, 10, Some(40), 8_600_000),
|
||||
(20, 80, 10, Some(100), 4_850_000),
|
||||
(0, 0, 0, Some(0), 0),
|
||||
];
|
||||
for (text, image, output, cached, expected_ticks) in cases {
|
||||
let mut usage = json!({
|
||||
"input_tokens":text+image,
|
||||
"input_tokens_details":{"text_tokens":text,"image_tokens":image},
|
||||
"output_tokens":output,
|
||||
"output_tokens_details":{"text_tokens":0,"image_tokens":output},
|
||||
"total_tokens":text+image+output,
|
||||
});
|
||||
if let Some(cached) = cached {
|
||||
usage["input_tokens_details"]["cached_tokens"] = json!(cached);
|
||||
}
|
||||
let response = serde_json::to_vec(&json!({"data":[{"b64_json":"AAEC"}],"usage":usage}))
|
||||
.expect("response");
|
||||
for (kind, model) in [
|
||||
(ImageRequestKind::Generation, "gpt-image-2"),
|
||||
(ImageRequestKind::Edit, "gpt-image-2-2026-04-21"),
|
||||
] {
|
||||
let events = image_metering_events(kind, &response, model).await;
|
||||
let costs = events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(index, event)| {
|
||||
event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
.filter_map(move |fact| match fact {
|
||||
GatewayEvent::CalculatedCost(cost) => Some((index, *cost)),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(costs.len(), 1);
|
||||
let (cost_index, cost) = costs[0];
|
||||
assert_eq!(cost.total().amount().scaled(), expected_ticks);
|
||||
assert_eq!(cost.into_estimate().source().as_str(), "calculated");
|
||||
let wire_index = events
|
||||
.iter()
|
||||
.position(|event| event.wire_event().is_some())
|
||||
.expect("wire event");
|
||||
assert!(cost_index < wire_index);
|
||||
assert_eq!(
|
||||
events[wire_index]
|
||||
.wire_event()
|
||||
.and_then(|wire| wire.raw_json_body())
|
||||
.map(|body| body.as_ref()),
|
||||
Some(response.as_slice())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_prices_should_remain_unknown_when_modality_or_model_is_uncertain() {
|
||||
let valid = json!({
|
||||
"input_tokens":100,
|
||||
"input_tokens_details":{"text_tokens":20,"image_tokens":80},
|
||||
"output_tokens":10,
|
||||
"output_tokens_details":{"text_tokens":0,"image_tokens":10},
|
||||
"total_tokens":110,
|
||||
});
|
||||
let mut cases = vec![
|
||||
("gpt-image-future", valid.clone()),
|
||||
("gpt-image-2-future", valid.clone()),
|
||||
("gpt-image-2", json!(null)),
|
||||
(
|
||||
"gpt-image-2",
|
||||
json!({"input_tokens":100,"output_tokens":10}),
|
||||
),
|
||||
];
|
||||
for (pointer, value) in [
|
||||
("/input_tokens", json!(101)),
|
||||
("/input_tokens_details/text_tokens", json!(null)),
|
||||
("/input_tokens_details/image_tokens", json!("80")),
|
||||
("/output_tokens_details/text_tokens", json!(1)),
|
||||
("/output_tokens_details/image_tokens", json!(9)),
|
||||
("/output_tokens", json!(-1)),
|
||||
("/total_tokens", json!(111)),
|
||||
] {
|
||||
let mut usage = valid.clone();
|
||||
*usage.pointer_mut(pointer).expect("existing field") = value;
|
||||
cases.push(("gpt-image-2", usage));
|
||||
}
|
||||
for cached in [json!(40), json!(101), json!(null), json!("40")] {
|
||||
let mut usage = valid.clone();
|
||||
usage["input_tokens_details"]["cached_tokens"] = cached;
|
||||
cases.push(("gpt-image-2", usage));
|
||||
}
|
||||
cases.push(("gpt-image-2", json!({
|
||||
"input_tokens":u64::MAX,"input_tokens_details":{"text_tokens":u64::MAX,"image_tokens":0},
|
||||
"output_tokens":10,
|
||||
})));
|
||||
for (model, usage) in cases {
|
||||
let response = serde_json::to_vec(&json!({"data":[{"b64_json":"AAEC"}],"usage":usage}))
|
||||
.expect("response");
|
||||
let events = image_metering_events(ImageRequestKind::Generation, &response, model).await;
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.flat_map(|event| event.canonical_facts())
|
||||
.all(|fact| !matches!(fact, GatewayEvent::CalculatedCost(_)))
|
||||
);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.find_map(|event| event.wire_event().and_then(|wire| wire.raw_json_body()))
|
||||
.map(|body| body.as_ref()),
|
||||
Some(response.as_slice())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn image_usage_events(
|
||||
kind: ImageRequestKind,
|
||||
response: &[u8],
|
||||
) -> Vec<gateway_core::event::ProviderEvent> {
|
||||
image_metering_events(kind, response, "gpt-image-2").await
|
||||
}
|
||||
|
||||
async fn image_metering_events(
|
||||
kind: ImageRequestKind,
|
||||
response: &[u8],
|
||||
model: &str,
|
||||
) -> Vec<gateway_core::event::ProviderEvent> {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let server = MockServer::start().await;
|
||||
let endpoint = match kind {
|
||||
ImageRequestKind::Generation => "/codex/images/generations",
|
||||
ImageRequestKind::Edit => "/codex/images/edits",
|
||||
};
|
||||
Mock::given(method("POST"))
|
||||
.and(path(endpoint))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(response.to_vec(), "application/json"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = provider_with_base_url(&store, server.uri());
|
||||
let payload = RawJsonPayload::new(
|
||||
"openai",
|
||||
Bytes::from(
|
||||
serde_json::to_vec(&json!({"model":model,"prompt":"draw a tree"}))
|
||||
.expect("request JSON"),
|
||||
),
|
||||
)
|
||||
.expect("image payload");
|
||||
let operation = Operation::GenerateImage(ImageRequest::from_raw_json(kind, payload));
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
planned_provider_endpoint_request("openai", operation),
|
||||
context("req_image_usage", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare image stream");
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
events.push(event.expect("image provider event"));
|
||||
}
|
||||
server.verify().await;
|
||||
events
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_endpoint_returns_the_exact_upstream_error_response() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -5631,6 +5922,52 @@ fn request_observation_reads_openai_metadata_without_changing_the_operation() {
|
||||
assert!(matches!(operation, Operation::Generate(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_observation_should_read_models_without_rewriting_or_requiring_a_catalog() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let provider = provider(&store);
|
||||
let client_key = ClientApiKeyId::new("key_endpoint_model").expect("client key");
|
||||
for model in [
|
||||
json!("gpt-image-future"),
|
||||
json!("gpt-5.6-sol"),
|
||||
json!(null),
|
||||
json!(42),
|
||||
json!(""),
|
||||
] {
|
||||
let body = serde_json::to_vec(&json!({"model":model,"future":9007199254740993_u64}))
|
||||
.expect("body");
|
||||
let payload =
|
||||
RawJsonPayload::new("openai", Bytes::copy_from_slice(&body)).expect("payload");
|
||||
let operations = [
|
||||
Operation::GenerateImage(ImageRequest::from_raw_json(
|
||||
ImageRequestKind::Generation,
|
||||
payload.clone(),
|
||||
)),
|
||||
Operation::GenerateImage(ImageRequest::from_raw_json(
|
||||
ImageRequestKind::Edit,
|
||||
payload.clone(),
|
||||
)),
|
||||
Operation::Search(StandaloneSearchRequest::from_raw_json(payload)),
|
||||
];
|
||||
for operation in operations {
|
||||
let observation = provider.request_observation(&operation, &client_key);
|
||||
assert_eq!(
|
||||
observation
|
||||
.requested_model
|
||||
.as_ref()
|
||||
.map(PublicModelId::as_str),
|
||||
model.as_str().filter(|model| !model.is_empty()),
|
||||
);
|
||||
let payload = match &operation {
|
||||
Operation::GenerateImage(request) => request.payload(),
|
||||
Operation::Search(request) => request.payload(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(payload.body().as_ref(), body.as_slice());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_observation_preserves_the_raw_reasoning_effort() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -152,6 +152,7 @@ impl Provider for GrokBuildProvider {
|
||||
}
|
||||
let semantics = codex_responses_request_semantics(payload.body(), payload.context());
|
||||
ProviderRequestObservation {
|
||||
requested_model: None,
|
||||
reasoning_effort: semantics.reasoning_effort,
|
||||
reasoning_preset: semantics.reasoning_preset.map(str::to_owned),
|
||||
request_kind: semantics.request_kind,
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.3.27"
|
||||
version: "3.3.28"
|
||||
|
||||
Reference in New Issue
Block a user