From b2da0752aad531965093f41c8ce2432f36ccbca2 Mon Sep 17 00:00:00 2001 From: zhuofeng Date: Wed, 20 May 2026 17:21:33 +0800 Subject: [PATCH] feat: support openai-api parse --- .../http/openai_normal_usage.pcap | Bin 0 -> 4191 bytes .../flow_generator/http/openai_stream.pcap | Bin 0 -> 203700 bytes .../http/openai_stream_usage.pcap | Bin 0 -> 203928 bytes .../http/openai_stream_v537.pcap | Bin 0 -> 49729 bytes agent/src/config/config.rs | 87 ++ agent/src/config/handler.rs | 17 +- agent/src/flow_generator/protocol_logs.rs | 26 +- .../flow_generator/protocol_logs/consts.rs | 2 +- .../src/flow_generator/protocol_logs/http.rs | 940 +++++++++++- .../protocol_logs/openai_api.rs | 1265 +++++++++++++++++ server/agent_config/README-CH.md | 628 ++++++-- server/agent_config/README.md | 637 +++++++-- server/agent_config/template.yaml | 335 +++++ 13 files changed, 3731 insertions(+), 206 deletions(-) create mode 100644 agent/resources/test/flow_generator/http/openai_normal_usage.pcap create mode 100644 agent/resources/test/flow_generator/http/openai_stream.pcap create mode 100644 agent/resources/test/flow_generator/http/openai_stream_usage.pcap create mode 100644 agent/resources/test/flow_generator/http/openai_stream_v537.pcap create mode 100644 agent/src/flow_generator/protocol_logs/openai_api.rs diff --git a/agent/resources/test/flow_generator/http/openai_normal_usage.pcap b/agent/resources/test/flow_generator/http/openai_normal_usage.pcap new file mode 100644 index 0000000000000000000000000000000000000000..df21d493f9b098abc8b3f40e4d32357d8d9ffdc1 GIT binary patch literal 4191 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 literal 0 HcmV?d00001 diff --git a/agent/resources/test/flow_generator/http/openai_stream.pcap b/agent/resources/test/flow_generator/http/openai_stream.pcap new file mode 100644 index 0000000000000000000000000000000000000000..2e0f460b95e891b47d1486668bb19299d899b4fb GIT binary patch literal 203700 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
uu;2)aex!Kq2Iuv}YD!w4Ah0H zZKiTiF#-(|+MWFiEIGm=gBGvcarWhOR3`MHr^EL%L2k{iV>(7fu`up z&4x3W>jF!Tu*jlC!*8*%C|N*qBVe(AFKK}jrc~Rl$SpP&11ySAF#;Cf7Vdp{|0smM z=x50h7K3QITK(}f{GEaV8@xWVY4AvG+1(k#Zk0) z=C|0=B@0Nd1QttYl$gqPweZ~ez@6=B2d7P`;`UE&v84-(qS)D%q2knV*Y9lq@5(!A zXw@s$)nqnlTD9vFH?)e$cEQK1(}s~vTD5Chbwa;v@;B<^^>|!~YwlOSc=FuY(m1@8 zSJ(@r{E?+hxaHC9so$+#m)s!bGE`K`WW^z@_$07mZ|djofq&evqD@xp#GvANF<*)Q j654h4OIW$nyE_(Cu}xM?9K+8zCw<11yQkW>gTD127PAW% literal 0 HcmV?d00001 diff --git a/agent/resources/test/flow_generator/http/openai_stream_usage.pcap b/agent/resources/test/flow_generator/http/openai_stream_usage.pcap new file mode 100644 index 0000000000000000000000000000000000000000..37e3dee487d1fd02c3d147bb4b5995c3ab31df99 GIT binary patch literal 203928 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⁡Y~HvN6~sUXAc8yRTiCc~sMrLERRp02ZZO2u zoyHo4*b)KJZM3*wWDvxU9&PAocIr;OM)7`;go+OA7nvtkWUjUGwNissa)QNbvWWJQ zV~c5v4GScf1dDSsM2k$a_`$0fvY56wjK!0v=zxW_yLXpVa)QMgvd{xi*kbxP!ve`Y z35ymh#04sgEPnD^OdqE$c!3&+iViGLb{;?LreYHy))IssfWiC!nGO7iGIASa|^LU6q?4v5q9ldmmtynAy^hKyDKt(fYX95?_Ur znAuWGa7#=@MF(1!?(ony$2gQYMT9Hm~MCm6&+xxZh_%<6`H`X zi5T=uCT5uPzQKUdo&m%DT!p1Y=`t}G7dz&>ABJHWDmuVmwn)7!wjvTwYnA?wDnR^-b$j# zRua*3PgrFBJVONet%b<428E@E_kSRful*wP=V=jsCUX-iI`B-Ul~alBDm9@qwvmIL z%ft=~%=_6$Z4(@3wka%~)cr^fS$>BFd_S8{>b9Yx11EJ&1A!qHw(aLsZi2*ilF&m> zSYqL0Mn@pGZIGzhLzMW5Br^RH3m?-R!4kVs(E*9(gAzwskZ7iI6C`$!#Eo8VEb+{} zh6Hlk4T+2d@c`;)k}!7eXYSP!d^)io6|a*6s2qtR!%kw*6PcJ{QMAE;(Ds8NenR1h z+UkZKezX73O}jg){6Y+$c(9>Odr`DzD5G!MrJ!QqiU0gfyWrsy>HAlNw(hjlxfXh3 zl!{R3ja`JIC!a9N;t~c5B1{3w!e_*z+FuF9IFediLZk3e?J-nz;HWlhWwM>4+D$4s z!D2UA=;0^XEdDesklZm?RM;q5{6-dE_#0&LpJ6OsKt%^Eg1c6wFSYT$S3Q-SV6lfR z^mHb+SYqDKMshEZ#Uat+ce420Z?S~$XLDb?jEXK;Ox>Tc)5bHJbyRYK#a^<|^H11f zsri03l6x5z4bK#oo<;kEEQ~d3DZii1m&ad2MF%dA+Zlq^PQ@lb>?4Sx-ba`rmKpQL z+ak8t08#N1u_^u}i2r#NLp%7gF!RRSzJ-boG)30BslnAYUV?0(k`pZUlZ778#1_vQ z^TsK;w_x$f*J4xrMHa>a^(@aD=cYKXEZ%aUDeSy__l=58fOwuD^zajgSZ?0WMr`K+ zG3IaaJnu@lrdZDRv-v#lLsWF&JkQc?)Hy0QLE->O=vy!%E}(v)dxH3t-rCTM_9};6LFS zVkLinHeU+-92Fh76li6R$5EA<;Bbf>3V3@fJ3JR-v;$K691gW=7ZDDdsz#8*r(VU- zZv9-0ZU^2Wf~e@g4q<1GM>7?h0Fgovdi)7PtSV|iAhsYNN;fMa+&QWmNf00TAyyUD z5PUB2B`P{_*c3dpF)d}ejm1eNCs-UNi+o;k*(`oFERfunu&CL&i11ygRdbNVdtSv* zQ>^+`Ta?fjp)RAM1K)+3F=K1yghd(0Y}*B%o1zN=f;uSkB~?nBkkH% zbCJZyUd2#jtl{UzdDs386&={MTeY&_@Qg}La5zd1dISnPtleRB1XBAA4tZxw2eDOi zhjUoFLw5up#Quef4jjZLT3{%vLK7H{5rdx2#0=}q``HNXFE9*!PGrbK491?lj_+q{ zhIWyt=m0|}3k;K0Xad7=V#w*OA*6+lG?P9^MI zY?`fN6Z+vKK}33=UWVA%(11W}1pv|G9dVb=9}Z$;Lk*#K>2^g>(Scps%40U8RceC6 z3*?|@GO@#^TMY-KRs;^`FNqCNfE>aRq5};PW#gjXBPun);S@Ri?R|W+IT*97 z+aa}^;BYZdQQ@Lr)k5U(j#n|%5SzoyvTk<^D!Slc<0<7IRBD35X>z#YrN$0hjC-Y& z+AVPCQ@W^d4ZLdMa1LAeUMU~9Mk$9|95`UI!SJLCO<;J581%3cX4q=XvTldaqQEdR zrl@quuLv=`?!kuIVJpwF)|dR+-GPb@T=L6Uu^|1xEDHy(GgWee#mi)&=bf;{wk5_E zf#mLh#iT|>rAvNA$>LSN#kM7Si_n+++EqqH2QK-g9bJZs^IYgqXvH6&MC zB_~+CN*2F+$+5+b{)PpTtB#7_l#7sV5#2x*#xk{|zqa7R*jlLQz+vp=qtmZ(jY4dZ zw~7#F2tp4#VThgW4T#z*wpysTyM_o+oFLA6w4wcCXZtV^^;E^$4nPDqO|$^>% zi(kCt*kbq1h6R#q1dC-I#il4p7H7PQp{Ce=t zS^*+!y4Vyq5yUBfQ|x_1LvT~HM@0vkB7OGm;LK}1r$BOls^kQVx5z?IV`7Va2Mh}& z*B%w8E*C9Ilf{dEi+u;eSUil14p>}1I^){sWFooMDmlU899ifY4{WjjIl}_UJ&cO+ zJ4A~zWbuOEV*hhtEIOm20~S`^VnK2vRC0pF+hm~!J+Q^|Qw$3v*BKQ%9TzRilEq2C z#q(3bSae542Q0GIO}_TaP)M$?N=~qNhb%68$+5+O!G;Br>yClf}1Qa%^$Xyq}Hadch*%3(?{hvN-Nl3|SoH z``NsA2T;)gi}b})vX-y3U{OLPCs@2o7TqoM-`w01V`UsY@Z#Crts_02^Jrag&y{h&Ef~c0?7@7#o)@KMHE@28Wul`O1n~aLx62*qNJsiZb=^BC?Vg@QY&=A-BAR1EZr&1Ff zE|5d;YKP;43iw<{?!(p#t=*a2#pfC<|P|*d4YkdL=shv`(2@ao{Dl~!NGh)z_PMG1Oc|RMW zEkwmS8^nXxJBeYx2ODaKli}`XFF{2I4qnra&k9<3wxEehPO$hNS?Fm@Z1IA5KO4y{ zfyH+R#J#!_Ssd_Nyb$hw_6k&VV6VRRw^vBl{lh6R$_ii)eQ zh!$1JVwYDj)EcLcgt6F(iVj$$+jSST8 zQ7_NZeZki}4x*wH$5Ge%wQ7j1y^2kMNGAwAf0rR%8Dc;nwu7j6b8WFHY7oSBk2bVG zy)r~Ya8n#XMF*N9bJ{jLk3ik2k`pX4$U;xsV2f9~7#2wG2rNo86D?|z#Wugit6joa zoJ2(zEcQ%FU;MmCw)!p3+!w~;G%7k^ zp}uY~W719w78g`>XXz9Wmbz2w;9ocY{1l1qcd*-N5D z1F~4_RSY%7xp2>oXQHAD7VGEQx$^XZN=~r&o-EFJ$+5-T=5ymnE)y0#eik>1hGen9 zs~EC)o1Yu!jp7?rbYP>1v9V$IQ>h6KKaj&)UTW;{P6K13Kx*H>;m15Vr7M7UlY_B7 zz0)AfM)5r=IU*gBSE}*HN<&y4jN+n9uOT$=afz)8ij*6&vVeYA$~zc z2O7f4i9`>Tn$QqGk;5BTJG`62Xb7bC3mleJ78|1R|EuQSLZXPmIDl`v7A09)L?Lux ztPmQ_Rd&@0virqqqvtRgXQ#Bb8OvFUO1cmsRAgCX5M7`kD6j{i5_%B{f?k5QOuJ%j zzVz75%I$pLHs>^l>CnSKjGzz8`0dR9H|NaGWj}PdVpq&gOizL?17ZX!`d~nK^BCYY z5}N?x6GG7bCv0YV!uLUg*hT2y;{RwkO z@qN%BwG24yS}$~{L5IuE0g+;T(CEYz3qeIcIC$|G-~p1Fz~Kuz(B4eU;c1t1VuI9S zA#j+xB_z7SvBb^cX&0TC=oOCGc&O-uD;!=hRFTjG3}2CfHa=kt&(3fR5ZZWPn9v*& z-M(Im3>Tck;n^9=K=0JWCPGCYoJ72DJC?rG?!gJ&ERvkS;u~7f?s3ebe;;Q7$xQ@{ z?}vpJ%h00JY0re8^Nf^qR3Egsm(F^hp(&H|E~1{QtSg`Z(ppanNa z4b)N#di5_>2o-(s8P=P2Pbm_c0OAKi(B@1GVsIe`0kIVV#QeL$L9r4c&e^otlZnBF z6oMTrWlVcVyrgIjM+-$J8_fTlD3N6ms6|)vE zrn^`aK}8=}3|~F*?rx6<7LQ4C0*g_!p#7Qtu*h&0kX#X1>=_hVtVW9t&LZPtQ4AG* zVBuNk(&HpKfkg%_dhO(x#Y^ryI|j)WgT=Xz!h^*cwBY8bm+U;79V}$1=!1iWH(euc zkl6kXkqRILZO+6XUOnOl1;i!;MDM6@P^?9W(>85(f_nAHWl)5nq7MdzXYHO+Bsqb_ z7_^{`PngB)Zq5Rd3xh>NevarYuNE!ZoffaVT`VF{(FYd8UB}+H-SpsKktE3pEP`l3 zn=>(sHy1ezNG<{v0ZC}F4lPbOE#6#ou~49*4=mERdWO5a=&n4KBqy*4p#^Qu#4O$( z1sr+-WrXUN28IjBui$)c3rc6FI1|TWHH@pGL2HLF=^}| z3uwD_)zV3saAjq6I9eW#RLRm#Gp;AdKW*Bh8x^I|lCsjOWw30c*`(<$lCqh+vk}+# zN=lh5kwq<4Qua%lX;``eZ}!h0qNIdwY_l3k?H0>yt|#l#Vj7ZSBohf)vdkU2QQs6N zZwN26*0tv5=H+VI|KI#NSxUC3+jQ9K=G`Pzs~%UDtZA6UXPK6opsz$KNJuPs_IkLy z6}L#YH)!*Ujj@$=u#shX${V0!e$rmY>>+pvo)C}^6$Arr6+49$Q{!+QY5Sqy>nP8w M7&5pa2m_|(7jW#~0ssI2 literal 0 HcmV?d00001 diff --git a/agent/resources/test/flow_generator/http/openai_stream_v537.pcap b/agent/resources/test/flow_generator/http/openai_stream_v537.pcap new file mode 100644 index 0000000000000000000000000000000000000000..552544e0a2277d7483d7238416a4fd44bce9cd4a GIT binary patch literal 49729 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, + pub json_paths: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct OpenAIBizDimExtractors { + pub org_path: OpenAIBizDimExtractor, + pub user_id: OpenAIBizDimExtractor, + pub app_id: OpenAIBizDimExtractor, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct OpenAIUsageFieldPaths { + /// JSON paths (dot-notation) to read the input token count, tried in order. + pub input_tokens: Vec, + /// JSON paths (dot-notation) to read the output token count, tried in order. + pub output_tokens: Vec, + /// JSON paths (dot-notation) to read the total token count, tried in order. + pub total_tokens: Vec, + /// JSON paths (dot-notation) to read the cached token count, tried in order. + pub cached_tokens: Vec, +} + +impl Default for OpenAIUsageFieldPaths { + fn default() -> Self { + Self { + input_tokens: vec!["usage.prompt_tokens".to_string()], + output_tokens: vec!["usage.completion_tokens".to_string()], + total_tokens: vec!["usage.total_tokens".to_string()], + cached_tokens: vec![ + "usage.prompt_tokens_details.cached_tokens".to_string(), + "usage.cache_read_input_tokens".to_string(), + ], + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct OpenAIApiConfig { + pub enabled: bool, + pub path_prefixes: Vec, + pub path_suffixes: Vec, + pub request_body_max_bytes: usize, + pub response_event_max_bytes: usize, + pub sse_buffer_max_bytes: usize, + pub usage_field_paths: OpenAIUsageFieldPaths, + pub biz_dimension_extractors: OpenAIBizDimExtractors, +} + +impl Default for OpenAIApiConfig { + fn default() -> Self { + Self { + enabled: false, + path_prefixes: vec![], + path_suffixes: vec![ + "/v1/chat/completions".to_string(), + "/v1/responses".to_string(), + ], + request_body_max_bytes: 65536, + response_event_max_bytes: 32768, + sse_buffer_max_bytes: 131072, + usage_field_paths: OpenAIUsageFieldPaths::default(), + biz_dimension_extractors: OpenAIBizDimExtractors { + org_path: OpenAIBizDimExtractor { + headers: vec!["x-org-path".to_string()], + json_paths: vec![], + }, + user_id: OpenAIBizDimExtractor { + headers: vec!["x-user-id".to_string()], + json_paths: vec![], + }, + app_id: OpenAIBizDimExtractor { + headers: vec!["appid".to_string()], + json_paths: vec![], + }, + }, + } + } +} + #[derive(Clone, Default, Debug, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct ProtocolSpecialConfig { @@ -2037,6 +2123,7 @@ pub struct ProtocolSpecialConfig { pub net_sign: NetSignConfig, pub mysql: MysqlConfig, pub grpc: GrpcConfig, + pub openai_api: OpenAIApiConfig, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] diff --git a/agent/src/config/handler.rs b/agent/src/config/handler.rs index 50bfe7ebeb4..0e8de068278 100644 --- a/agent/src/config/handler.rs +++ b/agent/src/config/handler.rs @@ -55,9 +55,10 @@ use super::config::{Ebpf, EbpfFileIoEvent, ProcessMatcher, SymbolTable}; use super::{ config::{ ApiResources, Config, DpdkSource, ExtraLogFields, ExtraLogFieldsInfo, HttpEndpoint, - HttpEndpointMatchRule, Iso8583ParseConfig, NetSignParseConfig, OracleConfig, PcapStream, - PortConfig, ProcessorsFlowLogTunning, RequestLogTunning, SessionTimeout, TagFilterOperator, - Timeouts, UserConfig, WebSphereMqParseConfig, GRPC_BUFFER_SIZE_MIN, + HttpEndpointMatchRule, Iso8583ParseConfig, NetSignParseConfig, OpenAIApiConfig, + OracleConfig, PcapStream, PortConfig, ProcessorsFlowLogTunning, RequestLogTunning, + SessionTimeout, TagFilterOperator, Timeouts, UserConfig, WebSphereMqParseConfig, + GRPC_BUFFER_SIZE_MIN, }, ConfigError, KubernetesPollerType, TrafficOverflowAction, }; @@ -1205,6 +1206,7 @@ pub struct LogParserConfig { pub unconcerned_dns_nxdomain_trie: DomainNameTrie, pub mysql_decompress_payload: bool, pub mysql_endpoint_disabled: bool, + pub openai_api: OpenAIApiConfig, pub custom_app: CustomAppConfig, } @@ -1225,6 +1227,7 @@ impl Default for LogParserConfig { unconcerned_dns_nxdomain_trie: DomainNameTrie::default(), mysql_decompress_payload: true, mysql_endpoint_disabled: true, + openai_api: OpenAIApiConfig::default(), custom_app: CustomAppConfig::default(), } } @@ -1272,6 +1275,7 @@ impl fmt::Debug for LogParserConfig { ) .field("mysql_decompress_payload", &self.mysql_decompress_payload) .field("mysql_endpoint_disabled", &self.mysql_endpoint_disabled) + .field("openai_api_enabled", &self.openai_api.enabled) .field("custom_app", &self.custom_app) .finish() } @@ -2382,6 +2386,13 @@ impl TryFrom<(Config, UserConfig)> for ModuleConfig { .protocol_special_config .mysql .endpoint_disabled, + openai_api: conf + .processors + .request_log + .application_protocol_inference + .protocol_special_config + .openai_api + .clone(), #[cfg(not(feature = "enterprise"))] custom_app: CustomAppConfig::default(), #[cfg(feature = "enterprise")] diff --git a/agent/src/flow_generator/protocol_logs.rs b/agent/src/flow_generator/protocol_logs.rs index 13a6d8efb4b..7f48d454a8b 100644 --- a/agent/src/flow_generator/protocol_logs.rs +++ b/agent/src/flow_generator/protocol_logs.rs @@ -19,6 +19,7 @@ pub(crate) mod dns; pub(crate) mod fastcgi; pub(crate) mod http; pub(crate) mod mq; +pub(crate) mod openai_api; mod parser; pub mod pb_adapter; pub(crate) mod ping; @@ -413,15 +414,30 @@ impl AppProtoLogsBaseInfo { } // go http2 uprobe may merge multi times, if not req and resp merge can not set to session + // Save whether this entry was already a Session before the merge so we can + // decide whether to recompute rrt below. + let was_already_session = self.head.msg_type == LogMessageType::Session; if self.head.msg_type != log.head.msg_type { self.head.msg_type = LogMessageType::Session; } - self.head.rrt = if self.end_time > self.start_time { - (self.end_time.as_micros() - self.start_time.as_micros()) as u64 - } else { - 0 - }; + // Freeze rrt after the first req→resp merge. + // + // On the initial merge (Request + Response → Session), end_time equals the + // first-response packet time, so `end_time - start_time` naturally gives + // first-response latency — the same semantics as non-streaming HTTP. + // + // For multi-merge protocols (SSE streaming, Go HTTP2 uprobe), each + // continuation packet would push end_time forward and make rrt equal to + // the total stream duration instead. Skipping the recomputation once the + // entry is already a Session preserves the first-response latency value. + if !was_already_session { + self.head.rrt = if self.end_time > self.start_time { + (self.end_time.as_micros() - self.start_time.as_micros()) as u64 + } else { + 0 + }; + } if self.biz_type == 0 { self.biz_type = log.biz_type; diff --git a/agent/src/flow_generator/protocol_logs/consts.rs b/agent/src/flow_generator/protocol_logs/consts.rs index ea57563e0a3..b451ede801d 100644 --- a/agent/src/flow_generator/protocol_logs/consts.rs +++ b/agent/src/flow_generator/protocol_logs/consts.rs @@ -33,7 +33,7 @@ pub const HTTP_STATUS_CLIENT_ERROR_MIN: u16 = 400; pub const HTTP_STATUS_CLIENT_ERROR_MAX: u16 = 499; pub const HTTP_STATUS_SERVER_ERROR_MIN: u16 = 500; pub const HTTP_STATUS_SERVER_ERROR_MAX: u16 = 600; -pub const HTTP_RESP_MIN_LEN: usize = 13; // 响应行:"HTTP/1.1 200 " +pub const HTTP_RESP_MIN_LEN: usize = 12; // 响应行:"HTTP/1.1 200"(reason phrase 可省略,RFC 7230 允许) pub const HTTP_HOST_OFFSET: usize = 6; pub const HTTP_CONTENT_LENGTH_OFFSET: usize = 16; diff --git a/agent/src/flow_generator/protocol_logs/http.rs b/agent/src/flow_generator/protocol_logs/http.rs index d3a788f5ac6..f359de60dfa 100644 --- a/agent/src/flow_generator/protocol_logs/http.rs +++ b/agent/src/flow_generator/protocol_logs/http.rs @@ -23,6 +23,7 @@ use std::{ }; use hpack::Decoder; +use log::debug; use nom::{AsBytes, ParseTo}; use serde::Serialize; @@ -33,6 +34,7 @@ use public_derive::L7Log; use super::{ consts::*, + openai_api, pb_adapter::{ ExtendedInfo, KeyVal, L7ProtocolSendLog, L7Request, L7Response, MetricKeyVal, TraceInfo, }, @@ -339,6 +341,16 @@ pub struct HttpInfo { #[serde(skip)] metrics: Vec, + /// OpenAI API accumulated session state; Some only when this HttpInfo + /// carries OpenAI-specific data (request biz-dims, response metrics, etc.). + #[serde(skip)] + pub openai_session: Option>, + + /// True when this is an OpenAI streaming request that requires multi-merge. + /// Drives `need_merge()` for HTTP/1 streaming sessions. + #[serde(skip)] + openai_need_merge: bool, + #[serde(skip)] is_on_blacklist: bool, @@ -395,7 +407,7 @@ impl L7ProtocolInfoInterface for HttpInfo { fn need_merge(&self) -> bool { match self.raw_data_type { L7ProtoRawDataType::GoHttp2Uprobe => true, - _ => false, + _ => self.openai_need_merge, } } @@ -604,6 +616,14 @@ impl HttpInfo { if other.is_resp_end { self.is_resp_end = true; } + // For OpenAI multi-merge: the request entry is cached with is_req_end=false + // so the session aggregator doesn't discard it. Each response packet + // carries is_req_end=true so we propagate it here to let is_session_end() + // = is_req_end && is_resp_end eventually return true. + // For normal HTTP this is a no-op since responses never set is_req_end. + if other.is_req_end { + self.is_req_end = true; + } self.captured_response_byte += other.captured_response_byte; if other.status != L7ResponseStatus::Ok { @@ -642,6 +662,34 @@ impl HttpInfo { super::swap_if!(self, x_request_id_1, is_default, other); self.attributes.append(&mut other.attributes); self.metrics.append(&mut other.metrics); + + // Merge OpenAI session: the response (or final SSE packet) carries the + // fully-accumulated session. Always prefer the incoming session over the + // stored one because: + // • For non-streaming: the request stores a partial clone; the response + // has the complete session with parsed usage. + // • For streaming: the request stores None; the final SSE packet has the + // complete session. + // Replacing unconditionally is safe — SSE continuations that have not + // completed yet carry None, so the `if let` guard prevents overwriting. + if let Some(other_session) = other.openai_session.take() { + debug!( + "openai: merge – {} openai_session (kind={:?} events={} usage={:?})", + if self.openai_session.is_some() { + "replacing" + } else { + "setting" + }, + other_session.kind, + other_session.stream_event_count, + other_session.usage.as_ref().map(|u| u.total_tokens), + ); + self.openai_session = Some(other_session); + } + if other.openai_need_merge { + self.openai_need_merge = true; + } + Ok(()) } @@ -857,6 +905,38 @@ impl From for L7ProtocolSendLog { } } + // OpenAI API: populate attributes/metrics from the accumulated session state. + let openai_protocol_str = if let Some(session) = f.openai_session.take() { + let (ttft, tpot) = session.compute_timings(); + debug!( + "openai: converting to send log: kind={:?} stream={} usage_status={:?} \ + events={} ttft={:?} tpot={:?} tokens={:?} stream_end_ts={:?} req_ts={}", + session.kind, + session.is_stream, + session.usage_status, + session.stream_event_count, + ttft, + tpot, + session.usage.as_ref().map(|u| u.total_tokens), + session.stream_end_ts_us, + session.request_ts_us, + ); + // Biz dimension attrs (org_path/user_id/app_id) are pushed directly to + // f.attributes at REQUEST time so they appear even on timed-out sessions. + // populate_log also emits them (to capture body-sourced attrs from TCP + // continuation segments). Remove the direct-push duplicates first so the + // merged log has each attr exactly once with the latest session value. + f.attributes.retain(|kv| { + kv.key != openai_api::ATTR_BIZ_ORG_PATH + && kv.key != openai_api::ATTR_BIZ_USER_ID + && kv.key != openai_api::ATTR_BIZ_APP_ID + }); + session.populate_log(&mut f.attributes, &mut f.metrics); + Some(openai_api::BIZ_PROTOCOL.to_string()) + } else { + None + }; + L7ProtocolSendLog { req_len: f.req_content_length, resp_len: f.resp_content_length, @@ -913,6 +993,7 @@ impl From for L7ProtocolSendLog { user_agent: f.user_agent, referer: f.referer, rpc_service: f.service_name, + protocol_str: openai_protocol_str, attributes: { if f.attributes.is_empty() { None @@ -966,6 +1047,11 @@ pub struct HttpLog { http2_req_decoder: Option>, http2_resp_decoder: Option>, + /// Per-session OpenAI state accumulated across multiple response packets. + /// Created when an OpenAI streaming request is first seen; cleared when the + /// stream ends or the session is reset. + openai_session: Option>, + #[cfg(feature = "enterprise")] custom_field_store: Store, } @@ -1049,6 +1135,23 @@ impl L7ProtocolParserInterface for HttpLog { match self.proto { L7Protocol::Http1 => { + // Per-packet trace: only at debug level to avoid flooding production logs. + if config.openai_api.enabled && param.direction == PacketDirection::ServerToClient { + debug!( + "openai: flow={} parse_payload Http1 direction={:?} \ + len={} starts={:?} session={}", + param.flow_id, + param.direction, + payload.len(), + &payload[..payload.len().min(8)], + if self.openai_session.is_some() { + "Some" + } else { + "None" + }, + ); + } + let mut info = HttpInfo { proto: self.proto, is_tls: param.is_tls(), @@ -1056,27 +1159,51 @@ impl L7ProtocolParserInterface for HttpLog { ..Default::default() }; - let l7_payload = self.parse_http_v1( + // Try standard HTTP/1 parsing first. + let parse_result = self.parse_http_v1( payload, param, &mut info, #[cfg(feature = "enterprise")] custom_policies, - )?; - self.set_info_by_config( - param, - config, - payload, - Some(l7_payload), - &mut info, - #[cfg(feature = "enterprise")] - custom_policies, ); - if param.parse_log { - Ok(L7ParseResult::Single(L7ProtocolInfo::HttpInfo(info))) - } else { - Ok(L7ParseResult::None) + match parse_result { + Ok(l7_payload) => { + self.set_info_by_config( + param, + config, + payload, + Some(l7_payload), + &mut info, + #[cfg(feature = "enterprise")] + custom_policies, + ); + + // OpenAI API enhancement after successful HTTP parse. + self.handle_openai_http1(payload, l7_payload, param, config, &mut info); + + if param.parse_log { + Ok(L7ParseResult::Single(L7ProtocolInfo::HttpInfo(info))) + } else { + Ok(L7ParseResult::None) + } + } + Err(http_err) => { + // Not a valid HTTP/1 header – check if this is an OpenAI SSE + // continuation packet belonging to an active streaming session. + if let Some(sse_info) = + self.handle_openai_sse_continuation(payload, param, config) + { + if param.parse_log { + Ok(L7ParseResult::Single(L7ProtocolInfo::HttpInfo(sse_info))) + } else { + Ok(L7ParseResult::None) + } + } else { + Err(http_err) + } + } } } L7Protocol::Http2 | L7Protocol::Grpc | L7Protocol::Triple => { @@ -1190,6 +1317,11 @@ impl L7ProtocolParserInterface for HttpLog { new_log.perf_stats = self.perf_stats(); new_log.http2_req_decoder = self.http2_req_decoder.take(); new_log.http2_resp_decoder = self.http2_resp_decoder.take(); + // Preserve an active OpenAI streaming session across per-packet resets. + // reset() is called after every packet; without this the RESPONSE packet + // would find openai_session=None because the REQUEST packet's session was + // discarded. Matches the same pattern as http2_req/resp_decoder above. + new_log.openai_session = self.openai_session.take(); *self = new_log; } @@ -1346,6 +1478,457 @@ impl HttpLog { self.http2_resp_decoder = Some(Decoder::new_with_expected_headers(expected_headers_set)); } + // ─── OpenAI API integration helpers ───────────────────────────────────── + + /// Called after a successful HTTP/1 parse to apply OpenAI-specific logic: + /// - On request: create/init an OpenAISession if the path matches. + /// - On response: feed the body into the SSE state machine or parse JSON usage. + fn handle_openai_http1( + &mut self, + full_payload: &[u8], + body: &[u8], + param: &ParseParam, + config: &LogParserConfig, + info: &mut HttpInfo, + ) { + if !config.openai_api.enabled { + return; + } + + match info.msg_type { + LogMessageType::Request => { + if info.method != Method::Post { + return; + } + if !openai_api::is_openai_path(&info.path, config) { + debug!( + "openai: flow={} path={} not matched by prefixes={:?} suffixes={:?}", + param.flow_id, + info.path, + config.openai_api.path_prefixes, + config.openai_api.path_suffixes + ); + return; + } + + let kind = openai_api::kind_from_path(&info.path); + let mut session = Box::new(openai_api::OpenAISession::new( + kind, + false, + param.time, + config.openai_api.sse_buffer_max_bytes, + &config.openai_api.usage_field_paths, + )); + + self.extract_openai_headers_from_payload(full_payload, &mut session, config); + + if !body.is_empty() { + openai_api::parse_request_body(&mut session, body, config); + } + + // is_req_end must stay false: the session aggregator discards any + // first-seen packet with need_merge=true && (req_end || resp_end). + // The response side propagates is_req_end back via merge(). + info.stream_id = Some(session.stream_id); + info.is_resp_end = false; + info.openai_need_merge = true; + + debug!( + "openai: flow={} REQUEST path={} kind={:?} stream={} stream_id={} \ + biz_user={:?} biz_app={:?} biz_org={:?} body_bytes={}", + param.flow_id, + info.path, + kind, + session.is_stream, + session.stream_id, + session.biz_user_id, + session.biz_app_id, + session.biz_org_path, + body.len(), + ); + + // Write biz-dimension attributes on the request packet so they survive + // even if the final merged entry loses them. + if let Some(v) = &session.biz_org_path { + info.attributes.push(KeyVal { + key: openai_api::ATTR_BIZ_ORG_PATH.to_string(), + val: v.clone(), + }); + } + if let Some(v) = &session.biz_user_id { + info.attributes.push(KeyVal { + key: openai_api::ATTR_BIZ_USER_ID.to_string(), + val: v.clone(), + }); + } + if let Some(v) = &session.biz_app_id { + info.attributes.push(KeyVal { + key: openai_api::ATTR_BIZ_APP_ID.to_string(), + val: v.clone(), + }); + } + + // For non-streaming sessions, also attach a clone of the initial + // session state to the REQUEST info. If the response is never seen + // (packet drop, MTU issue, etc.) the session-aggregator times out + // the REQUEST entry but it will still be tagged as openai-api with + // the request-side metadata (path, biz dimensions). + // When the real response arrives, merge() replaces this clone with + // the response's fully-populated session. + if !session.is_stream { + info.openai_session = Some(session.clone()); + } + + self.openai_session = Some(session); + } + + LogMessageType::Response => { + let (stream_id, is_already_stream) = match self.openai_session.as_ref() { + Some(s) => (s.stream_id, s.is_stream), + None => { + debug!( + "openai: flow={} RESPONSE arrived but no openai_session \ + (mid-flow capture or session already finished)", + param.flow_id + ); + return; + } + }; + + info.stream_id = Some(stream_id); + // is_req_end=true will be propagated into the stored REQUEST entry + // via merge(), satisfying is_session_end() = is_req_end && is_resp_end. + info.is_req_end = true; + info.openai_need_merge = true; + + // Scan headers once and derive both flags from the same pass. + let (is_sse, is_chunked) = Self::response_sse_and_chunked(full_payload, body); + let is_stream = is_already_stream || is_sse; + + debug!( + "openai: flow={} RESPONSE stream_id={} status={:?} is_already_stream={} \ + is_sse={} is_chunked={} body_bytes={}", + param.flow_id, + stream_id, + info.status_code, + is_already_stream, + is_sse, + is_chunked, + body.len(), + ); + + // Propagate chunked flag to the session so continuation packets + // can decode chunk framing before feeding to the SSE state machine. + if is_chunked { + if let Some(s) = self.openai_session.as_mut() { + s.is_chunked_transfer = true; + } + } + + if is_stream { + let done = { + let session = self.openai_session.as_mut().unwrap(); + session.is_stream = true; + // Upgrade Unknown → Missing now that we know this is a stream. + // Unknown means "not yet determined"; Missing means "expected but + // not yet seen", which is the correct state for an in-progress SSE. + if session.usage_status == openai_api::UsageStatus::Unknown { + session.usage_status = openai_api::UsageStatus::Missing; + } + // For chunked SSE, the first response body is empty (headers + // only) so feed_sse is a no-op here; actual SSE events arrive + // in subsequent continuation packets. + session.feed_sse(body, param.time) + }; + info.is_resp_end = done; + debug!( + "openai: flow={} SSE response fed stream_id={} done={} \ + events={} usage_status={:?}", + param.flow_id, + stream_id, + done, + self.openai_session + .as_ref() + .map(|s| s.stream_event_count) + .unwrap_or(0), + self.openai_session + .as_ref() + .map(|s| s.usage_status) + .unwrap_or_default(), + ); + if done { + info.openai_session = self.openai_session.take(); + } + } else { + { + let session = self.openai_session.as_mut().unwrap(); + if !body.is_empty() { + openai_api::parse_response_json(session, body, config); + } + // Non-streaming: stream ends at the response packet. + session.stream_end_ts_us = Some(param.time); + } + info.is_resp_end = true; + info.openai_session = self.openai_session.take(); + debug!( + "openai: flow={} non-stream RESPONSE done stream_id={} usage_status={:?}", + param.flow_id, + stream_id, + info.openai_session + .as_ref() + .map(|s| s.usage_status) + .unwrap_or_default(), + ); + } + } + + _ => {} + } + } + + /// Scan raw HTTP/1 headers in `payload` and extract OpenAI biz dimensions. + fn extract_openai_headers_from_payload( + &self, + payload: &[u8], + session: &mut openai_api::OpenAISession, + config: &LogParserConfig, + ) { + let mut headers = parse_v1_headers(payload); + let _ = headers.next(); // skip request line + for line in headers { + if let Some(col) = line.find(':') { + if col + 1 >= line.len() { + continue; + } + // extract_biz_from_header uses eq_ignore_ascii_case internally, + // so no need to lowercase here. + let key = line[..col].trim(); + let val = line[col + 1..].trim(); + openai_api::extract_biz_from_header(session, key, val, config); + } + } + } + + /// Scan the HTTP/1 response headers once and return `(is_sse, is_chunked)`. + /// + /// Combining both checks avoids two separate O(n) scans for `\r\n\r\n`. + fn response_sse_and_chunked(full_payload: &[u8], body: &[u8]) -> (bool, bool) { + // Fast path: body already starts with SSE markers (no header scan needed). + let body_is_sse = body.starts_with(b"data:") || body.starts_with(b"event:"); + + // Find the end of headers (single O(n) scan). + let header_end = full_payload + .windows(4) + .position(|w| w == b"\r\n\r\n") + .unwrap_or(full_payload.len()); + let headers = &full_payload[..header_end]; + + // Case-insensitive header search without allocation: compare each + // window byte-by-byte with the lower-case needle. + let header_contains = |needle: &[u8]| -> bool { + if headers.len() < needle.len() { + return false; + } + headers.windows(needle.len()).any(|w| { + w.iter() + .zip(needle.iter()) + .all(|(a, b)| a.to_ascii_lowercase() == *b) + }) + }; + + let is_sse = body_is_sse || header_contains(b"text/event-stream"); + let is_chunked = header_contains(b"transfer-encoding: chunked"); + (is_sse, is_chunked) + } + + /// Handle raw TCP payload that is NOT a valid HTTP/1 header but belongs to + /// an active OpenAI session. Covers three cases: + /// + /// 1. **Request body continuation** (`ClientToServer`): when the POST body arrives + /// in a separate TCP segment from the headers, parse it to detect `"stream": true` + /// before the response arrives. + /// + /// 2. **SSE continuation** (`is_stream=true`, `ServerToClient`): feed the raw bytes + /// into the SSE state machine and forward progress to the session aggregator. + /// + /// 3. **Non-streaming fallback** (`is_stream=false`, `ServerToClient`): when + /// `parse_http_v1` fails for a non-streaming response (e.g., body-only TCP + /// segment, or an unusual response format), complete the session immediately + /// so the cached REQUEST is not left to time out. + fn handle_openai_sse_continuation( + &mut self, + payload: &[u8], + param: &ParseParam, + config: &LogParserConfig, + ) -> Option { + if !config.openai_api.enabled { + return None; + } + + let (stream_id, is_stream) = match self.openai_session.as_ref() { + Some(s) => (s.stream_id, s.is_stream), + None => { + // No active session — normal for non-OpenAI flows. + if param.direction == PacketDirection::ServerToClient { + debug!( + "openai: flow={} non-HTTP server→client payload but no active session", + param.flow_id, + ); + } + return None; + } + }; + + // ── Client→server continuation (request body in separate TCP segment) ── + // When the POST body arrives after the HTTP headers in a later TCP segment, + // parse_http_v1 fails for that segment. Parse the payload as a request body + // to pick up the "stream" flag before the response arrives. + if param.direction == PacketDirection::ClientToServer { + if !is_stream && !payload.is_empty() { + let session = self.openai_session.as_mut().unwrap(); + openai_api::parse_request_body(session, payload, config); + debug!( + "openai: flow={} request body continuation parsed stream_id={} is_stream={}", + param.flow_id, stream_id, session.is_stream, + ); + } + return None; + } + + // ── Non-streaming fallback ──────────────────────────────────────────── + // parse_http_v1 failed for a server→client packet while a non-streaming + // session is active. The packet is likely a body-continuation segment + // (headers were in a prior segment) or an oddly-formatted first response. + // Complete the session now so the cached REQUEST is not left to time out. + // + // EXCEPTION: any HTTP-looking payload (starts with "HTTP/") where + // parse_http_v1 failed — e.g. 1xx informational responses (100 Continue, + // 103 Early Hints) or a reason-phrase-less "HTTP/1.1 NNN" that was + // rejected by a too-strict length check. Preserve the session so the + // real response that follows can be matched. + if !is_stream { + // Don't consume the session for any packet that looks like an HTTP + // response header (starts with "HTTP/"). parse_http_v1 may have + // legitimately rejected it (1xx status, reason-phrase-less status + // line, unsupported version), but the actual response is coming. + if payload.starts_with(b"HTTP/") { + debug!( + "openai: flow={} HTTP-header-like packet rejected by parse_http_v1 \ + (preserving session stream_id={}), starts={:?}", + param.flow_id, + stream_id, + &payload[..payload.len().min(16)], + ); + return None; + } + let session = self.openai_session.as_mut().unwrap(); + if !payload.is_empty() { + // Best-effort: the payload might be the JSON body; extract usage + // if it parses. Failure is silent (usage_status stays Missing). + openai_api::parse_response_json(session, payload, config); + } + session.stream_end_ts_us = Some(param.time); + let mut info = HttpInfo { + proto: self.proto, + is_tls: param.is_tls(), + msg_type: LogMessageType::Response, + stream_id: Some(stream_id), + is_req_end: true, + is_resp_end: true, + openai_need_merge: true, + ..Default::default() + }; + info.openai_session = self.openai_session.take(); + debug!( + "openai: flow={} non-stream RESPONSE fallback stream_id={} \ + (HTTP parse failed, completing session) usage_status={:?}", + param.flow_id, + stream_id, + info.openai_session + .as_ref() + .map(|s| s.usage_status) + .unwrap_or_default(), + ); + return Some(info); + } + + // ── SSE continuation ───────────────────────────────────────────────── + + let done = { + let session = self.openai_session.as_mut().unwrap(); + if session.is_chunked_transfer { + // Decode HTTP chunked framing into the session's reusable scratch + // buffer (zero extra allocation per continuation packet). + let ok = { + // Temporarily move the scratch buffer out so we can mutably + // borrow both it and the rest of `session`. + let mut scratch = std::mem::take(&mut session.chunked_decode_buf); + // Terminal chunk can appear in the same TCP segment as the + // final SSE events (usage + [DONE]). Always feed decoded data + // first; mark stream done afterward. + let is_terminal = openai_api::decode_chunked_sse_into(payload, &mut scratch); + let sse_done = if !scratch.is_empty() { + session.feed_sse(&scratch, param.time) + } else { + false + }; + let result = if is_terminal { + if !session.stream_completed { + session.stream_completed = true; + } + session.stream_end_ts_us.get_or_insert(param.time); + true + } else { + sse_done + }; + session.chunked_decode_buf = scratch; // restore (reuses capacity) + result + }; + ok + } else { + session.feed_sse(payload, param.time) + } + }; + + debug!( + "openai: flow={} SSE continuation stream_id={} payload_bytes={} done={} \ + events={} usage_status={:?}", + param.flow_id, + stream_id, + payload.len(), + done, + self.openai_session + .as_ref() + .map(|s| s.stream_event_count) + .unwrap_or(0), + self.openai_session + .as_ref() + .map(|s| s.usage_status) + .unwrap_or_default(), + ); + + let mut info = HttpInfo { + proto: self.proto, + is_tls: param.is_tls(), + msg_type: LogMessageType::Response, + stream_id: Some(stream_id), + is_req_end: true, + is_resp_end: done, + openai_need_merge: true, + ..Default::default() + }; + + if done { + info.openai_session = self.openai_session.take(); + debug!( + "openai: flow={} SSE stream DONE stream_id={} – moving session to HttpInfo", + param.flow_id, stream_id, + ); + } + + Some(info) + } + fn http1_check_protocol(&mut self, payload: &[u8]) -> Option { let mut headers = parse_v1_headers(payload); let Some(first_line) = headers.next() else { @@ -3375,4 +3958,331 @@ mod tests { .check_payload("GET / HTTP/1.1\r\n\r\n".as_bytes(), ¶m) .is_some()); } + + // ── OpenAI API tests ──────────────────────────────────────────────────── + + /// Build a LogParserConfig with OpenAI API enabled, accepting any path that + /// contains "completions" as suffix, so that the test pcap paths (which may + /// not start with "/v1/") still match. + fn openai_test_config() -> LogParserConfig { + use crate::config::config::{ + OpenAIApiConfig, OpenAIBizDimExtractor, OpenAIBizDimExtractors, OpenAIUsageFieldPaths, + }; + LogParserConfig { + openai_api: OpenAIApiConfig { + enabled: true, + // Accept paths that end with "completions" so that the test pcap + // path /model-center/api/llm/openai/v1/chat/completions matches. + path_prefixes: vec![], + path_suffixes: vec!["completions".to_string()], + request_body_max_bytes: 65536, + response_event_max_bytes: 32768, + sse_buffer_max_bytes: 524288, + usage_field_paths: OpenAIUsageFieldPaths::default(), + biz_dimension_extractors: OpenAIBizDimExtractors { + org_path: OpenAIBizDimExtractor { + headers: vec!["x-org-path".to_string()], + json_paths: vec!["metadata.org_path".to_string()], + }, + user_id: OpenAIBizDimExtractor { + headers: vec!["x-user-id".to_string()], + json_paths: vec![ + "safety_identifier".to_string(), + "user".to_string(), + "source_aigc_appid".to_string(), + "metadata.user_id".to_string(), + ], + }, + app_id: OpenAIBizDimExtractor { + headers: vec!["x-app-id".to_string()], + json_paths: vec![ + "appid".to_string(), + "source_appid".to_string(), + "metadata.app_id".to_string(), + ], + }, + }, + }, + ..Default::default() + } + } + + /// Run all packets in a pcap file through the HTTP1 parser and collect the + /// resulting `HttpInfo` objects (merged request+response pairs). + fn run_openai_pcap(pcap_name: &str, config: &LogParserConfig) -> Vec { + let capture = Capture::load_pcap(Path::new(FILE_DIR).join(pcap_name)); + let log_cache = Rc::new(RefCell::new(L7PerfCache::new(L7_RRT_CACHE_CAPACITY))); + let mut packets: Vec<_> = capture.collect(); + if packets.is_empty() { + return vec![]; + } + + let first_dst_port = packets[0].lookup_key.dst_port; + let mut parser = HttpLog::new_v1(); + let mut results: Vec = Vec::new(); + + for packet in packets.iter_mut() { + packet.lookup_key.direction = if packet.lookup_key.dst_port == first_dst_port { + PacketDirection::ClientToServer + } else { + PacketDirection::ServerToClient + }; + let payload = match packet.get_l4_payload() { + Some(p) => p, + None => continue, + }; + + let param = &mut ParseParam::new( + packet as &MetaPacket, + Some(log_cache.clone()), + Default::default(), + #[cfg(any(target_os = "linux", target_os = "android"))] + Default::default(), + true, + true, + ); + param.set_captured_byte(payload.len()); + param.set_log_parser_config(config); + + match parser.parse_payload(payload, param) { + Ok(L7ParseResult::Single(L7ProtocolInfo::HttpInfo(info))) => { + // Merge responses / SSE continuations into the previous entry. + // A Request starts a new session; everything else merges into the current one. + if info.msg_type != LogMessageType::Request { + if let Some(last) = results.last_mut() { + let mut other = info; + let _ = last.merge(&mut other); + continue; + } + } + results.push(info); + } + _ => {} + } + } + results + } + + fn attr_val<'a>(info: &'a HttpInfo, key: &str) -> Option<&'a str> { + info.attributes + .iter() + .find(|kv| kv.key == key) + .map(|kv| kv.val.as_str()) + } + + fn metric_val(info: &HttpInfo, key: &str) -> Option { + info.metrics + .iter() + .find(|kv| kv.key == key) + .map(|kv| kv.val) + } + + #[test] + fn test_openai_normal_usage_pcap() { + let config = openai_test_config(); + let results = run_openai_pcap("openai_normal_usage.pcap", &config); + + // Expect at least one merged request+response. + assert!( + !results.is_empty(), + "no results from openai_normal_usage.pcap" + ); + let info = &results[0]; + + // openai_session should carry the final state. + let session = info.openai_session.as_ref().expect("openai_session absent"); + + // For a non-streaming response, usage should be available. + assert!( + matches!( + session.usage_status, + crate::flow_generator::protocol_logs::openai_api::UsageStatus::Available + ), + "usage_status should be Available, got {:?}", + session.usage_status + ); + let usage = session.usage.as_ref().expect("usage absent"); + assert!(usage.input_tokens > 0, "input_tokens should be > 0"); + assert!(usage.output_tokens > 0, "output_tokens should be > 0"); + } + + #[test] + fn test_openai_stream_usage_pcap() { + let config = openai_test_config(); + let results = run_openai_pcap("openai_stream_usage.pcap", &config); + + assert!( + !results.is_empty(), + "no results from openai_stream_usage.pcap" + ); + // Find the merged entry that has openai_session populated. + let info = results + .iter() + .find(|i| i.openai_session.is_some()) + .expect("no result with openai_session"); + + let session = info.openai_session.as_ref().unwrap(); + + // Streaming pcap should be detected as stream. + assert!(session.is_stream, "should be detected as stream"); + + // Usage should be available (stream includes usage in chunks). + assert!( + matches!( + session.usage_status, + crate::flow_generator::protocol_logs::openai_api::UsageStatus::Available + ), + "usage_status should be Available" + ); + + let usage = session.usage.as_ref().expect("usage absent"); + assert!(usage.input_tokens > 0, "input_tokens should be > 0"); + assert!(usage.output_tokens > 0, "output_tokens should be > 0"); + assert!( + session.stream_event_count > 0, + "stream_event_count should be > 0" + ); + } + + /// Regression test for `openai_stream_v537.pcap`. + /// + /// This pcap uses HTTP chunked-transfer-encoding where each SSE event is + /// spread across three chunks: `data:`, `{json}\n`, and `\r\n` (blank line). + /// After chunk decoding the event boundary is `\n\r\n` (not `\n\n`). + /// Also, every chunk in this pcap includes inline usage data. + #[test] + fn test_openai_stream_v537_pcap() { + let config = openai_test_config(); + let results = run_openai_pcap("openai_stream_v537.pcap", &config); + + assert!( + !results.is_empty(), + "no results from openai_stream_v537.pcap" + ); + + let info = results + .iter() + .find(|i| i.openai_session.is_some()) + .expect("no result with openai_session"); + let session = info.openai_session.as_ref().unwrap(); + + assert!(session.is_stream, "should be detected as stream"); + assert!( + matches!( + session.usage_status, + crate::flow_generator::protocol_logs::openai_api::UsageStatus::Available + ), + "usage_status should be Available (inline usage in every chunk), got {:?}", + session.usage_status, + ); + let usage = session.usage.as_ref().expect("usage absent"); + assert!(usage.input_tokens > 0, "input_tokens should be > 0"); + assert!(usage.output_tokens > 0, "output_tokens should be > 0"); + assert!( + session.stream_event_count > 0, + "stream_event_count should be > 0" + ); + assert!(session.stream_completed, "stream should be completed"); + } + + #[test] + fn test_openai_stream_pcap() { + let config = openai_test_config(); + let results = run_openai_pcap("openai_stream.pcap", &config); + + // The stream pcap without explicit usage may still produce a result. + // Just verify parsing doesn't panic and produces reasonable output. + // If there are results, verify stream is detected. + for info in &results { + if let Some(session) = &info.openai_session { + // If detected as openai, it should at least be kind=ChatCompletions. + assert!( + matches!( + session.kind, + crate::flow_generator::protocol_logs::openai_api::OpenAIKind::ChatCompletions + ), + "kind should be ChatCompletions" + ); + } + } + } + + /// Test that OpenAI metrics are correctly propagated into the L7ProtocolSendLog. + #[test] + fn test_openai_metrics_in_send_log() { + use crate::flow_generator::protocol_logs::openai_api::{ + OpenAIKind, OpenAISession, OpenAIUsage, UsageStatus, + }; + + // request_ts_us = 0, stream_end_ts_us = 5_000_000 µs → 5000 ms total. + let mut session = Box::new(OpenAISession::new( + OpenAIKind::ChatCompletions, + true, + 0, + 131072, + &Default::default(), + )); + session.usage = Some(OpenAIUsage { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + cached_tokens: None, + }); + session.usage_status = UsageStatus::Available; + session.first_output_ts_us = Some(100_000); + session.last_output_ts_us = Some(600_000); + session.stream_end_ts_us = Some(5_000_000); // 5 s after request + session.stream_event_count = 5; + session.stream_completed = true; + session.biz_user_id = Some("test-user".to_string()); + session.biz_app_id = Some("test-app".to_string()); + + let info = HttpInfo { + proto: L7Protocol::Http1, + msg_type: LogMessageType::Session, + openai_session: Some(session), + ..Default::default() + }; + + let send_log: L7ProtocolSendLog = info.into(); + let ext = send_log.ext_info.expect("ext_info absent"); + + // protocol_str should be "openai-api" + assert_eq!( + ext.protocol_str.as_deref(), + Some("openai-api"), + "protocol_str should be openai-api" + ); + + // Attributes should contain biz_user_id and biz_app_id. + let attrs = ext.attributes.expect("attributes absent"); + let attr_map: std::collections::HashMap<_, _> = attrs + .iter() + .map(|kv| (kv.key.as_str(), kv.val.as_str())) + .collect(); + assert_eq!(attr_map.get("biz_user_id"), Some(&"test-user")); + assert_eq!(attr_map.get("biz_app_id"), Some(&"test-app")); + + // Metrics should contain token counts. + let metrics = ext.metrics.expect("metrics absent"); + let metric_map: std::collections::HashMap<_, _> = + metrics.iter().map(|kv| (kv.key.as_str(), kv.val)).collect(); + assert_eq!(metric_map.get("llm_input_tokens"), Some(&100.0f32)); + assert_eq!(metric_map.get("llm_output_tokens"), Some(&50.0f32)); + assert_eq!(metric_map.get("llm_total_tokens"), Some(&150.0f32)); + + // TTFT and TPOT should be present. + assert!(metric_map.contains_key("llm_ttft_us"), "ttft missing"); + assert!(metric_map.contains_key("llm_tpot_us"), "tpot missing"); + + // Total stream duration: 5_000_000 µs - 0 µs = 5_000_000 µs. + let total_us = metric_map + .get("llm_total_stream_us") + .copied() + .expect("llm_total_stream_us missing"); + assert!( + (total_us - 5_000_000.0).abs() < 1.0, + "expected ~5_000_000 µs total stream, got {total_us}" + ); + } } diff --git a/agent/src/flow_generator/protocol_logs/openai_api.rs b/agent/src/flow_generator/protocol_logs/openai_api.rs new file mode 100644 index 00000000000..19f7d40c5a3 --- /dev/null +++ b/agent/src/flow_generator/protocol_logs/openai_api.rs @@ -0,0 +1,1265 @@ +/* + * Copyright (c) 2024 Yunshan Networks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! OpenAI API business sub-protocol enhancement layer. +//! +//! This module implements incremental recognition, field extraction, SSE state machine, +//! and metric calculation (TTFT/TPOT/tokens) on top of the existing HTTP1/HTTP2 parser. +//! It does NOT introduce a new L7Protocol; the `biz_protocol` in the final log is set +//! to "openai-api" while the native L7 protocol remains HTTP1 or HTTP2. + +use std::sync::atomic::{AtomicU32, Ordering}; + +use serde_json::Value; + +use crate::config::config::OpenAIUsageFieldPaths; +use crate::config::handler::LogParserConfig; +use crate::flow_generator::protocol_logs::pb_adapter::{KeyVal, MetricKeyVal}; + +// ─── synthetic stream-id counter (per-process, wraps around) ─────────────── +static OPENAI_SESSION_COUNTER: AtomicU32 = AtomicU32::new(1); + +fn next_openai_session_id() -> u32 { + OPENAI_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed) +} + +// ─── Public constants ─────────────────────────────────────────────────────── +pub const BIZ_PROTOCOL: &str = "openai-api"; + +// Attribute names +pub const ATTR_API_KIND: &str = "openai_api_kind"; +pub const ATTR_STREAM: &str = "openai_stream"; +pub const ATTR_USAGE_STATUS: &str = "openai_usage_status"; +pub const ATTR_STREAM_COMPLETE: &str = "llm_stream_complete"; +pub const ATTR_ABORT_REASON: &str = "llm_abort_reason"; +pub const ATTR_BIZ_ORG_PATH: &str = "biz_org_path"; +pub const ATTR_BIZ_USER_ID: &str = "biz_user_id"; +pub const ATTR_BIZ_APP_ID: &str = "biz_app_id"; + +// Metric names +pub const METRIC_REQUEST: &str = "llm_request"; +pub const METRIC_STREAM_REQUEST: &str = "llm_stream_request"; +pub const METRIC_TTFT_US: &str = "llm_ttft_us"; +pub const METRIC_TPOT_US: &str = "llm_tpot_us"; +pub const METRIC_INPUT_TOKENS: &str = "llm_input_tokens"; +pub const METRIC_OUTPUT_TOKENS: &str = "llm_output_tokens"; +pub const METRIC_TOTAL_TOKENS: &str = "llm_total_tokens"; +pub const METRIC_CACHED_TOKENS: &str = "llm_cached_tokens"; +pub const METRIC_STREAM_EVENT_COUNT: &str = "llm_stream_event_count"; +pub const METRIC_TOTAL_STREAM_US: &str = "llm_total_stream_us"; + +// ─── Enumerations ────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OpenAIKind { + #[default] + Unknown, + ChatCompletions, + Responses, +} + +impl OpenAIKind { + pub fn as_str(self) -> &'static str { + match self { + OpenAIKind::ChatCompletions => "chat_completions", + OpenAIKind::Responses => "responses", + OpenAIKind::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UsageStatus { + #[default] + Unknown, + Available, + Missing, + NotRequested, + StreamInterrupted, +} + +impl UsageStatus { + pub fn as_str(self) -> &'static str { + match self { + UsageStatus::Available => "available", + UsageStatus::Missing => "missing", + UsageStatus::NotRequested => "not_requested", + UsageStatus::StreamInterrupted => "stream_interrupted", + UsageStatus::Unknown => "unknown", + } + } +} + +// ─── Token usage ───────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Default)] +pub struct OpenAIUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cached_tokens: Option, +} + +// ─── Pre-compiled usage path pointers ──────────────────────────────────── + +/// Dot-separated paths compiled once into JSON Pointer strings (`/a/b/c`). +/// Stored in `OpenAISession` so the hot SSE path does zero allocation. +#[derive(Clone, Debug)] +pub struct CompiledUsagePaths { + pub input_tokens: Vec, + pub output_tokens: Vec, + pub total_tokens: Vec, + pub cached_tokens: Vec, +} + +impl CompiledUsagePaths { + pub fn from_config(cfg: &OpenAIUsageFieldPaths) -> Self { + let compile = |paths: &[String]| -> Vec { + paths.iter().map(|p| dot_path_to_pointer(p)).collect() + }; + Self { + input_tokens: compile(&cfg.input_tokens), + output_tokens: compile(&cfg.output_tokens), + total_tokens: compile(&cfg.total_tokens), + cached_tokens: compile(&cfg.cached_tokens), + } + } +} + +/// Compile a dot-separated path into a JSON Pointer string (`/a/b/c`). +/// Escapes `~` → `~0` and `/` → `~1` as required by RFC 6901. +fn dot_path_to_pointer(path: &str) -> String { + let mut ptr = String::with_capacity(path.len() + 1); + ptr.push('/'); + for ch in path.chars() { + match ch { + '.' => ptr.push('/'), + '~' => ptr.push_str("~0"), + '/' => ptr.push_str("~1"), + c => ptr.push(c), + } + } + ptr +} + +/// Lookup a u64 using pre-compiled JSON Pointer strings. Zero allocation. +#[inline] +fn extract_u64_by_ptrs(json: &Value, ptrs: &[String]) -> Option { + for ptr in ptrs { + if let Some(n) = json.pointer(ptr.as_str()).and_then(|v| v.as_u64()) { + return Some(n); + } + } + None +} + +/// Parse token usage from the top-level JSON using pre-compiled pointers. +pub fn parse_usage_from_json(json: &Value, ptrs: &CompiledUsagePaths) -> Option { + let input_tokens = extract_u64_by_ptrs(json, &ptrs.input_tokens)?; + let output_tokens = extract_u64_by_ptrs(json, &ptrs.output_tokens)?; + let total_tokens = + extract_u64_by_ptrs(json, &ptrs.total_tokens).unwrap_or(input_tokens + output_tokens); + let cached_tokens = extract_u64_by_ptrs(json, &ptrs.cached_tokens); + Some(OpenAIUsage { + input_tokens, + output_tokens, + total_tokens, + cached_tokens, + }) +} + +/// Best-effort extraction of usage field values from raw (potentially truncated) +/// response bytes without full JSON parsing. +/// +/// Searches for patterns like `"prompt_tokens":18` in the raw byte slice. +/// Returns `None` if any required field is absent (e.g., body is truncated +/// before those bytes). Only supports simple leaf paths (the last path +/// component is the field name to search for). +fn extract_usage_raw(data: &[u8], ptrs: &CompiledUsagePaths) -> Option { + // Max field name length we expect (e.g., "completion_tokens" = 17 chars). + // Pattern is `"":` so max pattern len = 1 + 17 + 2 = 20 bytes. + const MAX_PATTERN: usize = 32; + let find_field = |field: &[u8]| -> Option { + let pattern_len = field.len() + 3; // '"' + field + '":' + if pattern_len > MAX_PATTERN { + return None; + } + let mut pattern = [0u8; MAX_PATTERN]; + pattern[0] = b'"'; + pattern[1..1 + field.len()].copy_from_slice(field); + pattern[1 + field.len()] = b'"'; + pattern[2 + field.len()] = b':'; + let pattern_slice = &pattern[..pattern_len]; + + let pos = data.windows(pattern_len).position(|w| w == pattern_slice)?; + let rest = &data[pos + pattern_len..]; + // Skip optional whitespace. + let start = rest + .iter() + .position(|&b| !b.is_ascii_whitespace()) + .unwrap_or(0); + let digits = &rest[start..]; + let end = digits + .iter() + .position(|b| !b.is_ascii_digit()) + .unwrap_or(digits.len()); + if end == 0 { + return None; + } + std::str::from_utf8(&digits[..end]) + .ok()? + .parse::() + .ok() + }; + + // Extract the leaf field name from the last configured path (e.g., + // "usage.prompt_tokens" → "prompt_tokens"). + let leaf = |paths: &[String]| -> Option { + for path in paths { + let field = path.rsplit('.').next().unwrap_or(path.as_str()); + if let Some(v) = find_field(field.as_bytes()) { + return Some(v); + } + } + None + }; + + let input_tokens = leaf(&ptrs.input_tokens)?; + let output_tokens = leaf(&ptrs.output_tokens)?; + let total_tokens = leaf(&ptrs.total_tokens).unwrap_or(input_tokens + output_tokens); + Some(OpenAIUsage { + input_tokens, + output_tokens, + total_tokens, + cached_tokens: None, + }) +} + +// ─── Per-session state (lives in HttpLog) ──────────────────────────────── + +/// State accumulated across multiple packets for one OpenAI streaming session. +#[derive(Clone, Debug)] +pub struct OpenAISession { + pub kind: OpenAIKind, + pub is_stream: bool, + /// True when the HTTP response uses `Transfer-Encoding: chunked`. + /// SSE data is then wrapped in chunk framing and must be decoded before + /// the SSE state machine can parse events. + pub is_chunked_transfer: bool, + /// Synthetic stream-id assigned to the session for multi-merge matching. + pub stream_id: u32, + + /// Request packet timestamp (microseconds). + pub request_ts_us: u64, + /// Timestamp when the SSE stream ended (microseconds). + /// `None` until the terminal event ([DONE] / response.completed) is received, + /// or until the non-streaming JSON response is parsed. + pub stream_end_ts_us: Option, + /// Timestamp of the first SSE output event (microseconds). + pub first_output_ts_us: Option, + /// Timestamp of the most recent SSE output event (microseconds). + pub last_output_ts_us: Option, + + pub stream_event_count: u32, + pub stream_completed: bool, + + pub usage: Option, + pub usage_status: UsageStatus, + + pub biz_org_path: Option, + pub biz_user_id: Option, + pub biz_app_id: Option, + + /// Partial SSE bytes not yet forming a complete event (any supported separator). + pub sse_buf: Vec, + pub sse_buf_overflowed: bool, + + pub abort_reason: Option, + + pub config_sse_max: usize, + /// Pre-compiled JSON Pointer strings for token extraction. Computed once at + /// session creation so the SSE hot path does zero allocation. + pub usage_ptrs: CompiledUsagePaths, + /// Scratch buffer reused across calls to decode HTTP chunked framing. + /// Avoids a fresh heap allocation per SSE continuation packet for chunked streams. + pub chunked_decode_buf: Vec, +} + +impl OpenAISession { + pub fn new( + kind: OpenAIKind, + is_stream: bool, + request_ts_us: u64, + sse_buffer_max_bytes: usize, + usage_paths: &OpenAIUsageFieldPaths, + ) -> Self { + Self { + kind, + is_stream, + is_chunked_transfer: false, + stream_id: next_openai_session_id(), + request_ts_us, + stream_end_ts_us: None, + first_output_ts_us: None, + last_output_ts_us: None, + stream_event_count: 0, + stream_completed: false, + usage: None, + usage_status: if is_stream { + UsageStatus::Missing + } else { + UsageStatus::Unknown + }, + biz_org_path: None, + biz_user_id: None, + biz_app_id: None, + sse_buf: Vec::new(), + sse_buf_overflowed: false, + abort_reason: None, + config_sse_max: sse_buffer_max_bytes, + usage_ptrs: CompiledUsagePaths::from_config(usage_paths), + chunked_decode_buf: Vec::new(), + } + } + + /// Feed raw bytes (from a streaming HTTP response chunk) into the SSE buffer + /// and process complete events. Returns `true` when the stream has ended. + pub fn feed_sse(&mut self, data: &[u8], packet_ts_us: u64) -> bool { + // Append to SSE buffer with overflow protection. + let available = self.config_sse_max.saturating_sub(self.sse_buf.len()); + let done = if available == 0 { + if !self.sse_buf_overflowed { + self.sse_buf_overflowed = true; + self.abort_reason = Some("sse_buffer_overflow".to_string()); + } + // Still try to scan for terminal events in the new data. + self.has_terminal_in(data) + } else { + let to_append = available.min(data.len()); + self.sse_buf.extend_from_slice(&data[..to_append]); + if to_append < data.len() { + self.sse_buf_overflowed = true; + self.abort_reason = Some("sse_buffer_overflow".to_string()); + } + self.drain_events(packet_ts_us) + }; + if done { + self.stream_end_ts_us.get_or_insert(packet_ts_us); + } + done + } + + /// Returns true if the stream has ended (terminal marker found in raw bytes). + /// Operates directly on bytes to avoid any allocation. + fn has_terminal_in(&self, data: &[u8]) -> bool { + // Both `data:[DONE]` (no space) and `data: [DONE]` (with space) are valid. + contains_bytes(data, b"data:[DONE]") + || contains_bytes(data, b"data: [DONE]") + || contains_bytes(data, b"\"response.completed\"") + } + + /// Drain all complete SSE events from the buffer. + /// Handles all four separator forms (`\n\n`, `\n\r\n`, `\r\n\n`, `\r\n\r\n`). + /// Returns `true` when the stream has ended. + fn drain_events(&mut self, packet_ts_us: u64) -> bool { + // Temporarily take the buffer so we can borrow slices from it while + // mutating the rest of `self` inside `process_sse_event`. This avoids + // per-event Vec allocations and reduces drain() calls to one. + let mut buf = std::mem::take(&mut self.sse_buf); + let mut cursor = 0; + let mut done = false; + + loop { + let Some((rel_end, sep_len)) = find_event_end(&buf[cursor..]) else { + break; + }; + let abs_end = cursor + rel_end; + done = self.process_sse_event(&buf[cursor..abs_end], packet_ts_us); + cursor = abs_end + sep_len; + if done { + break; + } + } + + // Put remaining (unprocessed) bytes back in one shot. + buf.drain(..cursor); + // Release excess capacity: if the buffer shrank to less than half its + // allocated capacity, shrink to avoid holding onto a large allocation + // for the rest of the stream after a burst of unprocessed data. + if buf.capacity() > 4096 && buf.len() < buf.capacity() / 2 { + buf.shrink_to_fit(); + } + self.sse_buf = buf; + done + } + + /// Process one complete SSE event. Returns `true` if this is the terminal event. + fn process_sse_event(&mut self, event_bytes: &[u8], packet_ts_us: u64) -> bool { + // Skip events with invalid UTF-8 silently. + let Ok(text) = std::str::from_utf8(event_bytes) else { + return false; + }; + + let mut event_type = ""; + let mut data_line = ""; + + for line in text.lines() { + if let Some(v) = line.strip_prefix("event:") { + event_type = v.trim(); + } else if let Some(v) = line.strip_prefix("data:") { + data_line = v.trim(); + } + } + + // Empty data line: SSE comment or keepalive (e.g. `: ping`). Nothing to parse. + if data_line.is_empty() { + return false; + } + + // Check for Chat Completions stream terminator. + if data_line == "[DONE]" { + self.stream_completed = true; + if self.usage.is_some() { + self.usage_status = UsageStatus::Available; + } + return true; + } + + // Parse JSON data payload. + let json: Value = match serde_json::from_str(data_line) { + Ok(v) => v, + Err(_) => return false, + }; + + // Check for Responses API completion event. + let is_responses_completed = event_type == "response.completed" + || json.get("type").and_then(|t| t.as_str()) == Some("response.completed"); + + if is_responses_completed { + if let Some(usage) = parse_usage_from_json(&json, &self.usage_ptrs) { + self.usage = Some(usage); + self.usage_status = UsageStatus::Available; + } + self.stream_completed = true; + return true; + } + + // Extract usage whenever it is present. This covers both the dedicated + // Chat Completions usage chunk (choices=[]) and providers that embed + // usage in every content chunk. + if let Some(usage) = parse_usage_from_json(&json, &self.usage_ptrs) { + self.usage = Some(usage); + self.usage_status = UsageStatus::Available; + } + + // Usage-only chunk (choices=[]): not a terminal event; [DONE] follows. + if json + .get("choices") + .and_then(|v| v.as_array()) + .map(|a| a.is_empty()) + .unwrap_or(false) + { + return false; + } + + // Check if this is a valid output event. + let is_output_event = match self.kind { + OpenAIKind::ChatCompletions => { + // choices[0].delta.content non-empty + json.pointer("/choices/0/delta/content") + .and_then(|c| c.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false) + } + OpenAIKind::Responses => { + (event_type == "response.output_text.delta" + && json + .pointer("/delta") + .and_then(|d| d.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false)) + || event_type == "response.output_item.done" + } + OpenAIKind::Unknown => false, + }; + + if is_output_event { + if self.first_output_ts_us.is_none() { + self.first_output_ts_us = Some(packet_ts_us); + } + self.last_output_ts_us = Some(packet_ts_us); + self.stream_event_count = self.stream_event_count.saturating_add(1); + } + + false + } + + /// Compute final TTFT and TPOT from the accumulated state. + /// Returns `(ttft_us, tpot_us)` in microseconds. + /// + /// **Streaming**: TTFT = time to first SSE content event; TPOT = inter-event + /// span divided by (output_tokens − 1). + /// + /// **Non-streaming**: TTFT = response latency (request → response received); + /// TPOT = response latency / output_tokens. + pub fn compute_timings(&self) -> (Option, Option) { + if self.is_stream { + let ttft_us = self + .first_output_ts_us + .map(|first| first.saturating_sub(self.request_ts_us) as f64); + + let tpot_us = match ( + self.first_output_ts_us, + self.last_output_ts_us, + self.usage.as_ref(), + ) { + (Some(first), Some(last), Some(usage)) if usage.output_tokens > 0 => { + let span_us = last.saturating_sub(first); + let divisor = usage.output_tokens.saturating_sub(1).max(1); + Some(span_us as f64 / divisor as f64) + } + _ => None, + }; + + (ttft_us, tpot_us) + } else { + // Non-streaming: all tokens arrive with the response body. + // TTFT = response latency. TPOT = latency / output_tokens. + let Some(end_ts) = self.stream_end_ts_us else { + return (None, None); + }; + let ttft_us = end_ts.saturating_sub(self.request_ts_us) as f64; + let tpot_us = self.usage.as_ref().and_then(|u| { + if u.output_tokens > 0 { + Some(ttft_us / u.output_tokens as f64) + } else { + None + } + }); + (Some(ttft_us), tpot_us) + } + } + + /// Write computed attributes and metrics into the provided vectors. + pub fn populate_log(&self, attrs: &mut Vec, metrics: &mut Vec) { + // Attributes. + push_attr(attrs, ATTR_API_KIND, self.kind.as_str()); + push_attr( + attrs, + ATTR_STREAM, + if self.is_stream { "true" } else { "false" }, + ); + push_attr(attrs, ATTR_USAGE_STATUS, self.usage_status.as_str()); + + if self.is_stream { + push_attr( + attrs, + ATTR_STREAM_COMPLETE, + if self.stream_completed { + "true" + } else { + "false" + }, + ); + if let Some(reason) = &self.abort_reason { + push_attr(attrs, ATTR_ABORT_REASON, reason); + } + } + + if let Some(v) = &self.biz_org_path { + push_attr(attrs, ATTR_BIZ_ORG_PATH, v); + } + if let Some(v) = &self.biz_user_id { + push_attr(attrs, ATTR_BIZ_USER_ID, v); + } + if let Some(v) = &self.biz_app_id { + push_attr(attrs, ATTR_BIZ_APP_ID, v); + } + + // Metrics. + push_metric(metrics, METRIC_REQUEST, 1.0); + push_metric( + metrics, + METRIC_STREAM_REQUEST, + if self.is_stream { 1.0 } else { 0.0 }, + ); + + if self.is_stream && self.stream_event_count > 0 { + push_metric( + metrics, + METRIC_STREAM_EVENT_COUNT, + self.stream_event_count as f64, + ); + } + + let (ttft_us, tpot_us) = self.compute_timings(); + if let Some(v) = ttft_us { + push_metric(metrics, METRIC_TTFT_US, v); + } + if let Some(v) = tpot_us { + push_metric(metrics, METRIC_TPOT_US, v); + } + + if let Some(usage) = &self.usage { + push_metric(metrics, METRIC_INPUT_TOKENS, usage.input_tokens as f64); + push_metric(metrics, METRIC_OUTPUT_TOKENS, usage.output_tokens as f64); + push_metric(metrics, METRIC_TOTAL_TOKENS, usage.total_tokens as f64); + if let Some(cached) = usage.cached_tokens { + push_metric(metrics, METRIC_CACHED_TOKENS, cached as f64); + } + } + + // Total request-to-completion duration in microseconds. + // For streaming: request → final [DONE] event (full stream wall-clock time). + // For non-streaming: request → response body received (same as llm_ttft_us). + if let Some(end_ts) = self.stream_end_ts_us { + let total_us = end_ts.saturating_sub(self.request_ts_us) as f64; + push_metric(metrics, METRIC_TOTAL_STREAM_US, total_us); + } + } +} + +// ─── Request parsing helpers ────────────────────────────────────────────── + +/// Check whether a request path matches the configured OpenAI API paths. +/// +/// Matching rules (OR logic): +/// - The path matches if it starts with **any** entry in `path_prefixes` +/// **or** ends with **any** entry in `path_suffixes`. +/// - An empty list means that group contributes nothing to the match. +/// - If **both** lists are empty, no path matches (explicit configuration required). +pub fn is_openai_path(path: &str, config: &LogParserConfig) -> bool { + if !config.openai_api.enabled { + return false; + } + let cfg = &config.openai_api; + // Both empty → no explicit paths configured, match nothing. + if cfg.path_prefixes.is_empty() && cfg.path_suffixes.is_empty() { + return false; + } + let matches_prefix = cfg + .path_prefixes + .iter() + .any(|p| path.starts_with(p.as_str())); + let matches_suffix = cfg.path_suffixes.iter().any(|s| path.ends_with(s.as_str())); + matches_prefix || matches_suffix +} + +/// Determine the API kind from the path. +pub fn kind_from_path(path: &str) -> OpenAIKind { + if path.contains("/chat/completions") { + OpenAIKind::ChatCompletions + } else if path.contains("/responses") { + OpenAIKind::Responses + } else { + OpenAIKind::Unknown + } +} + +/// Extract business dimensions from HTTP request headers. +/// `key` is the raw (mixed-case) header name; comparison is case-insensitive. +pub fn extract_biz_from_header( + session: &mut OpenAISession, + key: &str, + val: &str, + config: &LogParserConfig, +) { + // Short-circuit once all dims are populated — avoids iterating extractor + // lists for every remaining header in a request with many headers. + if session.biz_org_path.is_some() + && session.biz_user_id.is_some() + && session.biz_app_id.is_some() + { + return; + } + + let extractors = &config.openai_api.biz_dimension_extractors; + + if session.biz_org_path.is_none() + && extractors + .org_path + .headers + .iter() + .any(|h| h.eq_ignore_ascii_case(key)) + { + session.biz_org_path = Some(val.to_string()); + } + if session.biz_user_id.is_none() + && extractors + .user_id + .headers + .iter() + .any(|h| h.eq_ignore_ascii_case(key)) + { + session.biz_user_id = Some(val.to_string()); + } + if session.biz_app_id.is_none() + && extractors + .app_id + .headers + .iter() + .any(|h| h.eq_ignore_ascii_case(key)) + { + session.biz_app_id = Some(val.to_string()); + } +} + +/// Parse an OpenAI request JSON body and update the session: +/// - Extract `stream` field +/// - Extract business dimensions via json_paths +/// +/// When the body is a TCP continuation segment (partial JSON), full parsing +/// fails. In that case a raw byte search is used to detect `"stream": true` +/// so streaming is correctly identified even when the request spans multiple +/// TCP segments and the `stream` key is not in the first segment. +pub fn parse_request_body(session: &mut OpenAISession, body: &[u8], config: &LogParserConfig) { + let limit = config.openai_api.request_body_max_bytes; + let slice = if body.len() > limit { + &body[..limit] + } else { + body + }; + + let Ok(json) = serde_json::from_slice::(slice) else { + // Partial body segment: fall back to a byte search for the stream flag. + // This handles cases where "stream": true lives in a later TCP segment + // of a multi-segment POST body. + if !session.is_stream { + if contains_bytes(slice, b"\"stream\":true") + || contains_bytes(slice, b"\"stream\": true") + { + session.is_stream = true; + // Upgrade Unknown → Missing: we now know this is a streaming + // request, so usage is expected but not yet seen. + if session.usage_status == UsageStatus::Unknown { + session.usage_status = UsageStatus::Missing; + } + } + } + return; + }; + + // stream flag + if let Some(stream) = json.get("stream").and_then(|v| v.as_bool()) { + session.is_stream = stream; + if stream && session.usage_status == UsageStatus::Unknown { + session.usage_status = UsageStatus::Missing; + } + } + + let extractors = &config.openai_api.biz_dimension_extractors; + + if session.biz_org_path.is_none() { + session.biz_org_path = extract_json_paths(&json, &extractors.org_path.json_paths); + } + if session.biz_user_id.is_none() { + session.biz_user_id = extract_json_paths(&json, &extractors.user_id.json_paths); + } + if session.biz_app_id.is_none() { + session.biz_app_id = extract_json_paths(&json, &extractors.app_id.json_paths); + } +} + +/// Extract the first matching non-empty string value from a list of dot-notation JSON paths. +/// Supports arbitrary depth: "a.b.c" → json pointer "/a/b/c". +fn extract_json_paths(json: &Value, paths: &[String]) -> Option { + for path in paths { + let ptr = dot_path_to_pointer(path); + if let Some(s) = json.pointer(&ptr).and_then(|v| v.as_str()) { + if !s.is_empty() { + return Some(s.to_string()); + } + } + } + None +} + +/// Parse a non-streaming (JSON) response body and extract token usage. +/// +/// If JSON parsing fails (most commonly because the body is truncated by +/// `l7_log_packet_size`), the usage fields are searched for directly in the +/// raw bytes as a best-effort fallback. Usage may still be unavailable when +/// the fields are in the portion of the body beyond the capture limit. +pub fn parse_response_json(session: &mut OpenAISession, body: &[u8], config: &LogParserConfig) { + let limit = config.openai_api.response_event_max_bytes; + let slice = if body.len() > limit { + &body[..limit] + } else { + body + }; + + let Ok(json) = serde_json::from_slice::(slice) else { + // JSON parse failed — body is likely truncated by l7_log_packet_size. + // Try a best-effort raw-byte extraction of the usage fields. This + // succeeds only when the fields happen to fall within the captured bytes. + if let Some(usage) = extract_usage_raw(slice, &session.usage_ptrs) { + session.usage = Some(usage); + session.usage_status = UsageStatus::Available; + } else { + session.usage_status = UsageStatus::Missing; + } + return; + }; + + if let Some(usage) = parse_usage_from_json(&json, &session.usage_ptrs) { + session.usage = Some(usage); + session.usage_status = UsageStatus::Available; + } else { + session.usage_status = UsageStatus::Missing; + } +} + +// ─── Chunked transfer encoding decoder ─────────────────────────────────── + +/// Strip HTTP chunked-transfer-encoding framing from `payload`, writing the +/// decoded bytes into `out` (which is cleared first so the caller's buffer +/// capacity is reused across calls — zero extra allocation per invocation). +/// +/// Returns `true` when the HTTP terminal chunk (`0\r\n\r\n`) is found, +/// signalling stream end. Returns `false` otherwise (normal chunk or partial +/// packet). `out` may contain decoded data even when `true` is returned — +/// callers must feed it before acting on the terminal signal. +/// +/// If the payload does not look like valid chunk headers the remaining bytes +/// are appended to `out` unchanged so the SSE parser can still attempt to +/// process them. +pub fn decode_chunked_sse_into(payload: &[u8], out: &mut Vec) -> bool { + out.clear(); + let mut pos = 0; + + while pos < payload.len() { + // Find the end of the chunk-size line (terminated by \r\n). + let Some(crlf) = payload[pos..].windows(2).position(|w| w == b"\r\n") else { + // No \r\n — treat the rest as raw data (e.g., partial packet). + out.extend_from_slice(&payload[pos..]); + break; + }; + + let size_bytes = &payload[pos..pos + crlf]; + let Ok(size_str) = std::str::from_utf8(size_bytes) else { + out.extend_from_slice(&payload[pos..]); + break; + }; + // Ignore chunk extensions (after ';'). + let size_str = size_str + .find(';') + .map(|i| &size_str[..i]) + .unwrap_or(size_str) + .trim(); + + let Ok(chunk_size) = usize::from_str_radix(size_str, 16) else { + // Not a valid hex size — not chunked encoding; copy as-is. + out.extend_from_slice(&payload[pos..]); + break; + }; + + if chunk_size == 0 { + return true; // Terminal chunk — HTTP stream has ended. + } + + pos += crlf + 2; // Skip the chunk-size line including \r\n. + + // Append chunk data (handle partial last chunk). + // saturating_add guards against crafted chunk sizes near usize::MAX. + let data_end = pos.saturating_add(chunk_size).min(payload.len()); + out.extend_from_slice(&payload[pos..data_end]); + pos = data_end; + + // Skip the chunk's trailing \r\n terminator (may be absent if partial). + if pos + 2 <= payload.len() && payload[pos] == b'\r' && payload[pos + 1] == b'\n' { + pos += 2; + } + } + + false +} + +/// Allocating wrapper around `decode_chunked_sse_into` for call sites that +/// cannot pass a reusable buffer (e.g., tests). Returns `None` when the +/// terminal chunk is found, `Some(decoded)` otherwise. +pub fn decode_chunked_sse(payload: &[u8]) -> Option> { + let mut out = Vec::with_capacity(payload.len()); + if decode_chunked_sse_into(payload, &mut out) { + None + } else { + Some(out) + } +} + +// ─── SSE helper ─────────────────────────────────────────────────────────── + +/// Find the end of the first complete SSE event in `buf`. +/// +/// All four separator forms allowed by the SSE spec share the invariant that +/// a `\n` is immediately followed by either another `\n` (blank LF line) or +/// `\r\n` (blank CRLF line). Scanning for that covers `\n\n` (2 B), +/// `\n\r\n` / `\r\n\n` (3 B), and `\r\n\r\n` (4 B via the inner `\n`). +/// +/// Returns `(end, sep_len)` where `buf[..end]` is the event content and +/// `buf[end..end+sep_len]` is the separator. Any trailing `\r` left in the +/// event content by a CRLF line ending is stripped by `.lines()` in +/// `process_sse_event`. +#[inline] +fn find_event_end(buf: &[u8]) -> Option<(usize, usize)> { + let len = buf.len(); + if len < 2 { + return None; + } + for i in 0..len - 1 { + if buf[i] != b'\n' { + continue; + } + if buf[i + 1] == b'\n' { + return Some((i, 2)); + } + if i + 2 < len && buf[i + 1] == b'\r' && buf[i + 2] == b'\n' { + return Some((i, 3)); + } + } + None +} + +/// Byte-level substring search. Zero allocation. +#[inline] +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) +} + +// ─── Attribute/metric push helpers ──────────────────────────────────────── + +fn push_attr(attrs: &mut Vec, key: &str, val: &str) { + attrs.push(KeyVal { + key: key.to_string(), + val: val.to_string(), + }); +} + +fn push_metric(metrics: &mut Vec, key: &str, val: f64) { + metrics.push(MetricKeyVal { + key: key.to_string(), + val: val as f32, + }); +} + +// ─── Unit tests ───────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_session(kind: OpenAIKind, is_stream: bool) -> OpenAISession { + OpenAISession::new(kind, is_stream, 1_000_000, 131072, &Default::default()) + } + + #[test] + fn test_chat_completions_sse_ttft_tpot() { + let mut s = make_session(OpenAIKind::ChatCompletions, true); + s.request_ts_us = 0; + + // First output event at t=100ms (1 token). + let chunk1 = b"data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"; + let done = s.feed_sse(chunk1, 100_000); + assert!(!done); + assert_eq!(s.stream_event_count, 1); + assert_eq!(s.first_output_ts_us, Some(100_000)); + assert_eq!(s.last_output_ts_us, Some(100_000)); + + // More content tokens at t=500ms (4 more tokens = 5 total output tokens), + // then usage chunk and DONE – all in the same feed call. + let chunk2 = concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\" w\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"or\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"ld\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"!\"}}]}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n", + "data: [DONE]\n\n", + ); + let done = s.feed_sse(chunk2.as_bytes(), 500_000); + assert!(done); + assert!(s.stream_completed); + assert_eq!(s.usage_status, UsageStatus::Available); + let usage = s.usage.as_ref().unwrap(); + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.output_tokens, 5); + assert_eq!(s.stream_event_count, 5); // 1 + 4 + assert_eq!(s.last_output_ts_us, Some(500_000)); + + let (ttft, tpot) = s.compute_timings(); + // ttft = (100_000 - 0) = 100_000 µs + assert!((ttft.unwrap() - 100_000.0).abs() < 1.0); + // tpot = (500_000 - 100_000) / max(5-1, 1) = 400_000 / 4 = 100_000 µs + assert!((tpot.unwrap() - 100_000.0).abs() < 1.0); + } + + #[test] + fn test_responses_api_sse_completed() { + // The Responses API `response.completed` event embeds usage under + // `response.usage.*`, so we configure paths accordingly. + use crate::config::config::OpenAIUsageFieldPaths; + let paths = OpenAIUsageFieldPaths { + input_tokens: vec!["response.usage.input_tokens".to_string()], + output_tokens: vec!["response.usage.output_tokens".to_string()], + total_tokens: vec!["response.usage.total_tokens".to_string()], + ..Default::default() + }; + let mut s = OpenAISession::new(OpenAIKind::Responses, true, 0, 131072, &paths); + + let chunk = b"event: response.output_text.delta\ndata: {\"delta\":\"Hi\"}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":5,\"output_tokens\":3,\"total_tokens\":8}}}\n\n"; + let done = s.feed_sse(chunk, 200_000); + assert!(done); + assert!(s.stream_completed); + assert_eq!(s.usage_status, UsageStatus::Available); + let usage = s.usage.as_ref().unwrap(); + assert_eq!(usage.input_tokens, 5); + assert_eq!(usage.output_tokens, 3); + assert_eq!(s.stream_event_count, 1); + } + + #[test] + fn test_non_streaming_usage_extraction() { + let mut s = make_session(OpenAIKind::ChatCompletions, false); + let body = br#"{"usage":{"prompt_tokens":20,"completion_tokens":10,"total_tokens":30}}"#; + let config = crate::config::handler::LogParserConfig::default(); + parse_response_json(&mut s, body, &config); + assert_eq!(s.usage_status, UsageStatus::Available); + let usage = s.usage.as_ref().unwrap(); + assert_eq!(usage.input_tokens, 20); + assert_eq!(usage.output_tokens, 10); + assert_eq!(usage.total_tokens, 30); + } + + #[test] + fn test_non_streaming_timings() { + // Non-streaming: request at t=0, response at t=2_000_000 µs. + // TTFT = TOTAL = 2_000_000 µs, TPOT = 2_000_000 / 10 output_tokens = 200_000 µs. + let mut s = OpenAISession::new( + OpenAIKind::ChatCompletions, + false, + 0, + 131072, + &Default::default(), + ); + s.usage = Some(OpenAIUsage { + input_tokens: 20, + output_tokens: 10, + total_tokens: 30, + cached_tokens: None, + }); + s.usage_status = UsageStatus::Available; + s.stream_end_ts_us = Some(2_000_000); // 2 seconds in µs + + let (ttft, tpot) = s.compute_timings(); + assert!( + (ttft.unwrap() - 2_000_000.0).abs() < 1.0, + "ttft should be 2_000_000 µs, got {:?}", + ttft + ); + assert!( + (tpot.unwrap() - 200_000.0).abs() < 1.0, + "tpot should be 200_000 µs, got {:?}", + tpot + ); + + // Verify populate_log emits the metrics. + let mut attrs = Vec::new(); + let mut metrics = Vec::new(); + s.populate_log(&mut attrs, &mut metrics); + + let metric_map: std::collections::HashMap<_, _> = + metrics.iter().map(|kv| (kv.key.as_str(), kv.val)).collect(); + assert!(metric_map.contains_key("llm_ttft_us"), "ttft missing"); + assert!(metric_map.contains_key("llm_tpot_us"), "tpot missing"); + assert!( + metric_map.contains_key("llm_total_stream_us"), + "total_stream_us missing" + ); + assert!((metric_map["llm_ttft_us"] - 2_000_000.0).abs() < 1.0); + assert!((metric_map["llm_tpot_us"] - 200_000.0).abs() < 1.0); + assert!((metric_map["llm_total_stream_us"] - 2_000_000.0).abs() < 1.0); + } + + fn make_config_with_json_paths() -> crate::config::handler::LogParserConfig { + use crate::config::config::{ + OpenAIApiConfig, OpenAIBizDimExtractor, OpenAIBizDimExtractors, OpenAIUsageFieldPaths, + }; + crate::config::handler::LogParserConfig { + openai_api: OpenAIApiConfig { + enabled: true, + path_prefixes: vec!["/v1/chat/completions".to_string()], + path_suffixes: vec![], + request_body_max_bytes: 65536, + response_event_max_bytes: 32768, + sse_buffer_max_bytes: 131072, + usage_field_paths: OpenAIUsageFieldPaths::default(), + biz_dimension_extractors: OpenAIBizDimExtractors { + org_path: OpenAIBizDimExtractor { + headers: vec![], + json_paths: vec![ + "metadata.org_path".to_string(), + "metadata.department_path".to_string(), + ], + }, + user_id: OpenAIBizDimExtractor { + headers: vec![], + json_paths: vec![ + "safety_identifier".to_string(), + "user".to_string(), + "metadata.user_id".to_string(), + ], + }, + app_id: OpenAIBizDimExtractor { + headers: vec![], + json_paths: vec![ + "metadata.app_id".to_string(), + "metadata.application_id".to_string(), + ], + }, + }, + }, + ..Default::default() + } + } + + #[test] + fn test_biz_dims_from_json() { + let mut s = make_session(OpenAIKind::ChatCompletions, false); + let config = make_config_with_json_paths(); + let body = + br#"{"user":"alice","safety_identifier":"si-001","metadata":{"org_path":"/root/eng","app_id":"app-42"}}"#; + parse_request_body(&mut s, body, &config); + // user_id: "safety_identifier" has priority over "user" + assert_eq!(s.biz_user_id.as_deref(), Some("si-001")); + assert_eq!(s.biz_org_path.as_deref(), Some("/root/eng")); + assert_eq!(s.biz_app_id.as_deref(), Some("app-42")); + } + + #[test] + fn test_biz_dims_three_level_json_path() { + // Regression test: paths deeper than 2 levels must work. + use crate::config::config::{ + OpenAIApiConfig, OpenAIBizDimExtractor, OpenAIBizDimExtractors, OpenAIUsageFieldPaths, + }; + let config = crate::config::handler::LogParserConfig { + openai_api: OpenAIApiConfig { + enabled: true, + path_prefixes: vec!["/v1/chat/completions".to_string()], + path_suffixes: vec![], + request_body_max_bytes: 65536, + response_event_max_bytes: 32768, + sse_buffer_max_bytes: 131072, + usage_field_paths: OpenAIUsageFieldPaths::default(), + biz_dimension_extractors: OpenAIBizDimExtractors { + org_path: OpenAIBizDimExtractor { + headers: vec![], + json_paths: vec!["context.meta.org".to_string()], + }, + user_id: OpenAIBizDimExtractor { + headers: vec![], + json_paths: vec![], + }, + app_id: OpenAIBizDimExtractor { + headers: vec![], + json_paths: vec![], + }, + }, + }, + ..Default::default() + }; + let mut s = make_session(OpenAIKind::ChatCompletions, false); + let body = br#"{"context":{"meta":{"org":"/root/deep"}}}"#; + parse_request_body(&mut s, body, &config); + assert_eq!(s.biz_org_path.as_deref(), Some("/root/deep")); + } + + #[test] + fn test_biz_dims_user_fallback() { + let mut s = make_session(OpenAIKind::ChatCompletions, false); + let config = make_config_with_json_paths(); + let body = br#"{"user":"bob","metadata":{"org_path":"/root/sales"}}"#; + parse_request_body(&mut s, body, &config); + // No safety_identifier, fall back to "user" + assert_eq!(s.biz_user_id.as_deref(), Some("bob")); + assert_eq!(s.biz_org_path.as_deref(), Some("/root/sales")); + } + + #[test] + fn test_sse_across_chunks() { + let mut s = make_session(OpenAIKind::ChatCompletions, true); + s.request_ts_us = 0; + + // Event split across two feed calls. + let part1 = b"data: {\"choices\":[{\"delta\":{\"content\":\"He"; + let done = s.feed_sse(part1, 50_000); + assert!(!done); + assert_eq!(s.stream_event_count, 0); + + let part2 = b"llo\"}}]}\n\ndata: [DONE]\n\n"; + let done = s.feed_sse(part2, 100_000); + assert!(done); + assert_eq!(s.stream_event_count, 1); + assert_eq!(s.first_output_ts_us, Some(100_000)); + } + + /// Verify that `find_event_end` and `feed_sse` handle the `\n\r\n` SSE separator. + /// + /// Some servers (e.g., `openai_stream_v537.pcap`) deliver SSE over HTTP + /// chunked transfer where each chunk only carries part of an SSE event: + /// - Chunk 1: `data:` + /// - Chunk 2: `{json}\n` (single LF from SSE data line) + /// - Chunk 3: `\r\n` (CRLF blank line = event separator) + /// + /// After `decode_chunked_sse_into` concatenates the chunks, the buffer + /// looks like `data:{json}\n\r\n`. This test verifies the parser handles + /// that `\n\r\n` separator correctly. + #[test] + fn test_sse_crlf_event_separator() { + let mut s = make_session(OpenAIKind::ChatCompletions, true); + s.request_ts_us = 0; + + // Simulate the decoded content from a chunked SSE packet in v537 format: + // data:{json}\n ← SSE data line (LF) + // \r\n ← CRLF blank line (event separator) + let event = b"data:{\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":1,\"total_tokens\":11}}\n\r\n"; + let done = s.feed_sse(event, 100_000); + assert!(!done); + assert_eq!(s.stream_event_count, 1, "should detect output event"); + assert_eq!( + s.usage_status, + UsageStatus::Available, + "inline usage should be extracted" + ); + let usage = s.usage.as_ref().unwrap(); + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.output_tokens, 1); + + // Terminator: data:[DONE]\n\r\n (same separator style) + let done_event = b"data:[DONE]\n\r\n"; + let done = s.feed_sse(done_event, 200_000); + assert!(done); + assert!(s.stream_completed); + } + + #[test] + fn test_tpot_missing_when_usage_absent() { + let mut s = make_session(OpenAIKind::ChatCompletions, true); + s.request_ts_us = 0; + s.first_output_ts_us = Some(100_000); + s.last_output_ts_us = Some(500_000); + // No usage -> TPOT should be None + let (_, tpot) = s.compute_timings(); + assert!(tpot.is_none()); + } +} diff --git a/server/agent_config/README-CH.md b/server/agent_config/README-CH.md index 6c557bd862c..d9e4492a5e9 100644 --- a/server/agent_config/README-CH.md +++ b/server/agent_config/README-CH.md @@ -2269,97 +2269,6 @@ inputs: - ebpf.profile.off_cpu(注意确认 `inputs.ebpf.profile.off_cpu.disabled` 已配置为 **false**) - ebpf.profile.memory(注意确认 `inputs.ebpf.profile.memory.disabled` 已配置为 **false**) -### 智能体治理 {#inputs.proc.ai_agent} - -#### HTTP 端点 {#inputs.proc.ai_agent.http_endpoints} - -**标签**: - -`hot_update` -ee_feature - -**FQCN**: - -`inputs.proc.ai_agent.http_endpoints` - -**默认值**: -```yaml -inputs: - proc: - ai_agent: - http_endpoints: - - /v1/chat/completions - - /v1/embeddings - - /v1/responses -``` - -**模式**: -| Key | Value | -| ---- | ---------------------------- | -| Type | string | - -**详细描述**: - -用于识别智能体的 HTTP 端点前缀,命中后会标记进程为 AI Agent。 - -#### 最大载荷大小 {#inputs.proc.ai_agent.max_payload_size} - -**标签**: - -`hot_update` -ee_feature - -**FQCN**: - -`inputs.proc.ai_agent.max_payload_size` - -**默认值**: -```yaml -inputs: - proc: - ai_agent: - max_payload_size: 0 -``` - -**模式**: -| Key | Value | -| ---- | ---------------------------- | -| Type | int | -| Unit | byte | -| Range | [0, 2147483647] | - -**详细描述**: - -AI Agent 流重组最大载荷大小,0 表示不限。 - -#### 文件 IO 事件 {#inputs.proc.ai_agent.file_io_enabled} - -**标签**: - -`hot_update` -ee_feature - -**FQCN**: - -`inputs.proc.ai_agent.file_io_enabled` - -**默认值**: -```yaml -inputs: - proc: - ai_agent: - file_io_enabled: true -``` - -**模式**: -| Key | Value | -| ---- | ---------------------------- | -| Type | bool | - -**详细描述**: - -是否开启 AI Agent 文件 IO 事件采集。 - ### 符号表 {#inputs.proc.symbol_table} #### Golang 特有 {#inputs.proc.symbol_table.golang_specific} @@ -8523,6 +8432,543 @@ processors: 开启后所有 gRPC 数据包都认为是 `stream` 类型,并且会将 `data` 类型数据包上报,同时延迟计算的响应使用带有 `grpc-status` 字段的。 +##### OpenAI API {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api} + +OpenAI 兼容 API 协议增强配置。开启后,对命中配置路径的 HTTP/1 和 HTTP/2 流量进行 +OpenAI API 识别,并附加 LLM 专用指标(TTFT/TPOT/Token 用量)和业务维度属性(组织/用户/应用)。 + +###### 开启 OpenAI API 解析 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.enabled} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.enabled` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + enabled: false +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | bool | + +**详细描述**: + +开启 OpenAI 兼容 API 协议增强。 + +###### 路径前缀过滤 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_prefixes} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_prefixes` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + path_prefixes: [] +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +路径以任意配置前缀开头的 HTTP 请求将进行 OpenAI API 增强。path_prefixes 与 +path_suffixes 独立匹配,满足其中任意一个即可。列表为空时该组不参与匹配; +两个列表同时为空则不匹配任何路径。 + +###### 路径后缀过滤 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_suffixes} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_suffixes` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + path_suffixes: + - /v1/chat/completions + - /v1/responses +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +路径以任意配置后缀结尾的 HTTP 请求将进行 OpenAI API 增强。path_prefixes 与 +path_suffixes 独立匹配,满足其中任意一个即可。列表为空时该组不参与匹配; +两个列表同时为空则不匹配任何路径。 + +###### 请求体最大缓存字节数 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.request_body_max_bytes} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.request_body_max_bytes` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + request_body_max_bytes: 65536 +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | int | +| Unit | byte | +| Range | [1024, 1048576] | + +**详细描述**: + +请求体的最大缓存和解析字节数,用于提取 `stream` 标志和业务维度字段。 + +###### 响应事件最大解析字节数 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.response_event_max_bytes} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.response_event_max_bytes` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + response_event_max_bytes: 32768 +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | int | +| Unit | byte | +| Range | [1024, 1048576] | + +**详细描述**: + +单个 SSE 事件 JSON 的最大解析字节数(非流式响应体大小限制)。 + +###### SSE 缓冲区最大字节数 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.sse_buffer_max_bytes} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.sse_buffer_max_bytes` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + sse_buffer_max_bytes: 131072 +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | int | +| Unit | byte | +| Range | [4096, 4194304] | + +**详细描述**: + +SSE 事件跨多个响应包重组时的总缓冲上限。超限后丢弃后续事件并设置 +`attribute.llm_abort_reason=sse_buffer_overflow`。 + +###### Token 用量字段路径配置 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths} + +配置从 API 响应中提取 Token 用量的 JSON 路径。路径为相对于响应顶层 JSON 的点分格式 +(如 "usage.prompt_tokens" 解析为 json["usage"]["prompt_tokens"])。 +多个路径按顺序尝试,取第一个非空值。 + +####### 输入 Token 字段路径 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.input_tokens} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.input_tokens` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + input_tokens: + - usage.prompt_tokens +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 JSON 路径,用于读取输入(prompt)Token 数量。 + +####### 输出 Token 字段路径 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.output_tokens} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.output_tokens` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + output_tokens: + - usage.completion_tokens +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 JSON 路径,用于读取输出(completion)Token 数量。 + +####### 总 Token 字段路径 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.total_tokens} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.total_tokens` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + total_tokens: + - usage.total_tokens +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 JSON 路径,用于读取总 Token 数量。 +若均未命中,则自动计算为 input_tokens + output_tokens。 + +####### 缓存 Token 字段路径 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.cached_tokens} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.cached_tokens` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + cached_tokens: + - usage.prompt_tokens_details.cached_tokens + - usage.cache_read_input_tokens +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 JSON 路径,用于读取缓存(Prompt Cache 命中)Token 数量。 +usage.prompt_tokens_details.cached_tokens 对应 OpenAI API; +usage.cache_read_input_tokens 对应 Anthropic API。 +若均未命中,则不输出 llm_cached_tokens 指标。 + +###### 业务维度提取配置 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors} + +配置从请求头或 JSON Body 字段提取 org_path、user_id、app_id 的规则。 +每个维度采用"首个命中即停止"策略:先按顺序尝试 headers,若均未命中则 +按顺序尝试 json_paths,找到第一个非空值后不再继续后续候选项。 + +####### 组织架构路径 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path} + +提取组织架构路径维度的规则。 + +######## 请求头候选列表 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.headers} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.headers` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + org_path: + headers: + - x-org-path +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 HTTP 请求头名称,用于提取 org_path。 + +######## JSON 字段路径候选列表 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.json_paths} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.json_paths` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + org_path: + json_paths: [] +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 JSON Body 字段路径,用于提取 org_path。 +支持一级点号路径,如 "metadata.org_path"。 + +####### 用户标识 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id} + +提取业务用户标识维度的规则。 + +######## 请求头候选列表 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.headers} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.headers` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + user_id: + headers: + - x-user-id +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 HTTP 请求头名称,用于提取 user_id。 + +######## JSON 字段路径候选列表 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.json_paths} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.json_paths` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + user_id: + json_paths: [] +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 JSON Body 字段路径,用于提取 user_id。 +支持一级点号路径,如 "metadata.user_id"。 + +####### 应用标识 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id} + +提取应用标识维度的规则。 + +######## 请求头候选列表 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.headers} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.headers` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + app_id: + headers: + - appid +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 HTTP 请求头名称,用于提取 app_id。 + +######## JSON 字段路径候选列表 {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.json_paths} + +**标签**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.json_paths` + +**默认值**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + app_id: + json_paths: [] +``` + +**模式**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**详细描述**: + +按顺序尝试的 JSON Body 字段路径,用于提取 app_id。 +支持一级点号路径,如 "metadata.app_id"。 + #### 自定义协议解析 {#processors.request_log.application_protocol_inference.custom_protocols} **标签**: diff --git a/server/agent_config/README.md b/server/agent_config/README.md index 801c71fed45..f824307f9ae 100644 --- a/server/agent_config/README.md +++ b/server/agent_config/README.md @@ -2299,97 +2299,6 @@ Also ensure the global configuration parameters for related features are enabled - ebpf.profile.off_cpu (Ensure `inputs.ebpf.profile.off_cpu.disabled` is configured to **false**) - ebpf.profile.memory (Ensure `inputs.ebpf.profile.memory.disabled` is configured to **false**) -### AI Agent {#inputs.proc.ai_agent} - -#### HTTP Endpoints {#inputs.proc.ai_agent.http_endpoints} - -**Tags**: - -`hot_update` -ee_feature - -**FQCN**: - -`inputs.proc.ai_agent.http_endpoints` - -**Default value**: -```yaml -inputs: - proc: - ai_agent: - http_endpoints: - - /v1/chat/completions - - /v1/embeddings - - /v1/responses -``` - -**Schema**: -| Key | Value | -| ---- | ---------------------------- | -| Type | string | - -**Description**: - -HTTP endpoints for AI agent recognition. Requests that match any prefix will mark the process as AI Agent. - -#### Max Payload Size {#inputs.proc.ai_agent.max_payload_size} - -**Tags**: - -`hot_update` -ee_feature - -**FQCN**: - -`inputs.proc.ai_agent.max_payload_size` - -**Default value**: -```yaml -inputs: - proc: - ai_agent: - max_payload_size: 0 -``` - -**Schema**: -| Key | Value | -| ---- | ---------------------------- | -| Type | int | -| Unit | byte | -| Range | [0, 2147483647] | - -**Description**: - -Maximum payload size for AI agent reassembly. 0 means unlimited. - -#### File IO Enabled {#inputs.proc.ai_agent.file_io_enabled} - -**Tags**: - -`hot_update` -ee_feature - -**FQCN**: - -`inputs.proc.ai_agent.file_io_enabled` - -**Default value**: -```yaml -inputs: - proc: - ai_agent: - file_io_enabled: true -``` - -**Schema**: -| Key | Value | -| ---- | ---------------------------- | -| Type | bool | - -**Description**: - -Whether to enable AI Agent file IO event collection. - ### Symbol Table {#inputs.proc.symbol_table} #### Golang-specific {#inputs.proc.symbol_table.golang_specific} @@ -8712,6 +8621,552 @@ processors: When enabled, all gRPC packets are considered to be of the `stream` type, and the `data` will be reported, and the rrt calculation of the response will use the `grpc-status` field. +##### OpenAI API {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api} + +Configuration for OpenAI-compatible API protocol enhancement. +When enabled, HTTP/1 and HTTP/2 traffic matching the configured paths will be +recognised as OpenAI API calls and enriched with LLM-specific metrics and +business-dimension attributes (org_path, user_id, app_id). + +###### Enabled {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.enabled} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.enabled` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + enabled: false +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | bool | + +**Description**: + +Enable OpenAI-compatible API protocol enhancement. + +###### Path Prefixes {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_prefixes} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_prefixes` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + path_prefixes: [] +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +HTTP requests whose path starts with any of these prefixes will be treated +as OpenAI API calls. path_prefixes and path_suffixes are matched independently +with OR logic: a path is accepted if it satisfies either list. An empty list +contributes nothing; if both lists are empty, no paths will be matched. + +###### Path Suffixes {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_suffixes} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.path_suffixes` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + path_suffixes: + - /v1/chat/completions + - /v1/responses +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +HTTP requests whose path ends with any of these suffixes will be treated +as OpenAI API calls. path_prefixes and path_suffixes are matched independently +with OR logic: a path is accepted if it satisfies either list. An empty list +contributes nothing; if both lists are empty, no paths will be matched. + +###### Request Body Max Bytes {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.request_body_max_bytes} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.request_body_max_bytes` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + request_body_max_bytes: 65536 +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | int | +| Unit | byte | +| Range | [1024, 1048576] | + +**Description**: + +Maximum bytes of the request body to cache and parse for extracting +the `stream` flag and business-dimension fields. + +###### Response Event Max Bytes {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.response_event_max_bytes} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.response_event_max_bytes` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + response_event_max_bytes: 32768 +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | int | +| Unit | byte | +| Range | [1024, 1048576] | + +**Description**: + +Maximum bytes per SSE event JSON to parse (non-streaming response body limit). + +###### SSE Buffer Max Bytes {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.sse_buffer_max_bytes} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.sse_buffer_max_bytes` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + sse_buffer_max_bytes: 131072 +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | int | +| Unit | byte | +| Range | [4096, 4194304] | + +**Description**: + +Maximum total bytes buffered for SSE event reassembly across multiple +response packets. When exceeded, further events are dropped and +`attribute.llm_abort_reason=sse_buffer_overflow` is set. + +###### Usage Field Paths {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths} + +Configure the JSON paths used to extract token usage counts from API +responses. Paths use dot-notation relative to the top-level response JSON +(e.g. "usage.prompt_tokens" resolves as json["usage"]["prompt_tokens"]). +Multiple paths are tried in order; the first non-null value wins. + +####### Input Tokens {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.input_tokens} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.input_tokens` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + input_tokens: + - usage.prompt_tokens +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +JSON paths to read the input (prompt) token count, tried in order. + +####### Output Tokens {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.output_tokens} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.output_tokens` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + output_tokens: + - usage.completion_tokens +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +JSON paths to read the output (completion) token count, tried in order. + +####### Total Tokens {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.total_tokens} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.total_tokens` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + total_tokens: + - usage.total_tokens +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +JSON paths to read the total token count, tried in order. +If none match, total is computed as input_tokens + output_tokens. + +####### Cached Tokens {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.cached_tokens} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.usage_field_paths.cached_tokens` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + usage_field_paths: + cached_tokens: + - usage.prompt_tokens_details.cached_tokens + - usage.cache_read_input_tokens +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +JSON paths to read the cached (prompt cache hit) token count, tried in order. +usage.prompt_tokens_details.cached_tokens covers the OpenAI API; +usage.cache_read_input_tokens covers the Anthropic API. +If none match, the llm_cached_tokens metric is omitted. + +###### Business Dimension Extractors {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors} + +Configure how to extract org_path, user_id, and app_id from request +headers or JSON body fields. Extraction uses a first-match-wins strategy: +for each dimension, headers are tried in listed order first; if none match, +json_paths are tried in listed order. The first non-empty value found stops +further attempts for that dimension. + +####### Org Path {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path} + +Rules for extracting the organization/department path dimension. + +######## Headers {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.headers} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.headers` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + org_path: + headers: + - x-org-path +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +HTTP request header names to try, in order, for org_path extraction. + +######## JSON Paths {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.json_paths} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.org_path.json_paths` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + org_path: + json_paths: [] +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +JSON body field paths to try, in order, for org_path extraction. +Supports one level of dot notation, e.g. "metadata.org_path". + +####### User ID {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id} + +Rules for extracting the business user identifier dimension. + +######## Headers {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.headers} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.headers` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + user_id: + headers: + - x-user-id +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +HTTP request header names to try, in order, for user_id extraction. + +######## JSON Paths {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.json_paths} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.user_id.json_paths` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + user_id: + json_paths: [] +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +JSON body field paths to try, in order, for user_id extraction. +Supports one level of dot notation, e.g. "metadata.user_id". + +####### App ID {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id} + +Rules for extracting the application identifier dimension. + +######## Headers {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.headers} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.headers` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + app_id: + headers: + - appid +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +HTTP request header names to try, in order, for app_id extraction. + +######## JSON Paths {#processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.json_paths} + +**Tags**: + +agent_restart + +**FQCN**: + +`processors.request_log.application_protocol_inference.protocol_special_config.openai_api.biz_dimension_extractors.app_id.json_paths` + +**Default value**: +```yaml +processors: + request_log: + application_protocol_inference: + protocol_special_config: + openai_api: + biz_dimension_extractors: + app_id: + json_paths: [] +``` + +**Schema**: +| Key | Value | +| ---- | ---------------------------- | +| Type | string[] | + +**Description**: + +JSON body field paths to try, in order, for app_id extraction. +Supports one level of dot notation, e.g. "metadata.app_id". + #### Custom Protocol Parsing {#processors.request_log.application_protocol_inference.custom_protocols} **Tags**: diff --git a/server/agent_config/template.yaml b/server/agent_config/template.yaml index b10a5ba85c8..f5d1502064d 100644 --- a/server/agent_config/template.yaml +++ b/server/agent_config/template.yaml @@ -6138,6 +6138,341 @@ processors: # ch: |- # 开启后所有 gRPC 数据包都认为是 `stream` 类型,并且会将 `data` 类型数据包上报,同时延迟计算的响应使用带有 `grpc-status` 字段的。 streaming_data_enabled: false + # type: section + # name: OpenAI API + # description: + # en: |- + # Configuration for OpenAI-compatible API protocol enhancement. + # When enabled, HTTP/1 and HTTP/2 traffic matching the configured paths will be + # recognised as OpenAI API calls and enriched with LLM-specific metrics and + # business-dimension attributes (org_path, user_id, app_id). + # ch: |- + # OpenAI 兼容 API 协议增强配置。开启后,对命中配置路径的 HTTP/1 和 HTTP/2 流量进行 + # OpenAI API 识别,并附加 LLM 专用指标(TTFT/TPOT/Token 用量)和业务维度属性(组织/用户/应用)。 + openai_api: + # type: bool + # name: + # en: Enabled + # ch: 开启 OpenAI API 解析 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # Enable OpenAI-compatible API protocol enhancement. + # ch: |- + # 开启 OpenAI 兼容 API 协议增强。 + enabled: false + # type: string[] + # name: + # en: Path Prefixes + # ch: 路径前缀过滤 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # HTTP requests whose path starts with any of these prefixes will be treated + # as OpenAI API calls. path_prefixes and path_suffixes are matched independently + # with OR logic: a path is accepted if it satisfies either list. An empty list + # contributes nothing; if both lists are empty, no paths will be matched. + # ch: |- + # 路径以任意配置前缀开头的 HTTP 请求将进行 OpenAI API 增强。path_prefixes 与 + # path_suffixes 独立匹配,满足其中任意一个即可。列表为空时该组不参与匹配; + # 两个列表同时为空则不匹配任何路径。 + path_prefixes: [] + # type: string[] + # name: + # en: Path Suffixes + # ch: 路径后缀过滤 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # HTTP requests whose path ends with any of these suffixes will be treated + # as OpenAI API calls. path_prefixes and path_suffixes are matched independently + # with OR logic: a path is accepted if it satisfies either list. An empty list + # contributes nothing; if both lists are empty, no paths will be matched. + # ch: |- + # 路径以任意配置后缀结尾的 HTTP 请求将进行 OpenAI API 增强。path_prefixes 与 + # path_suffixes 独立匹配,满足其中任意一个即可。列表为空时该组不参与匹配; + # 两个列表同时为空则不匹配任何路径。 + path_suffixes: + - /v1/chat/completions + - /v1/responses + # type: int + # name: + # en: Request Body Max Bytes + # ch: 请求体最大缓存字节数 + # unit: byte + # range: [1024, 1048576] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # Maximum bytes of the request body to cache and parse for extracting + # the `stream` flag and business-dimension fields. + # ch: |- + # 请求体的最大缓存和解析字节数,用于提取 `stream` 标志和业务维度字段。 + request_body_max_bytes: 65536 + # type: int + # name: + # en: Response Event Max Bytes + # ch: 响应事件最大解析字节数 + # unit: byte + # range: [1024, 1048576] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # Maximum bytes per SSE event JSON to parse (non-streaming response body limit). + # ch: |- + # 单个 SSE 事件 JSON 的最大解析字节数(非流式响应体大小限制)。 + response_event_max_bytes: 32768 + # type: int + # name: + # en: SSE Buffer Max Bytes + # ch: SSE 缓冲区最大字节数 + # unit: byte + # range: [4096, 4194304] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # Maximum total bytes buffered for SSE event reassembly across multiple + # response packets. When exceeded, further events are dropped and + # `attribute.llm_abort_reason=sse_buffer_overflow` is set. + # ch: |- + # SSE 事件跨多个响应包重组时的总缓冲上限。超限后丢弃后续事件并设置 + # `attribute.llm_abort_reason=sse_buffer_overflow`。 + sse_buffer_max_bytes: 131072 + # type: section + # name: + # en: Usage Field Paths + # ch: Token 用量字段路径配置 + # description: + # en: |- + # Configure the JSON paths used to extract token usage counts from API + # responses. Paths use dot-notation relative to the top-level response JSON + # (e.g. "usage.prompt_tokens" resolves as json["usage"]["prompt_tokens"]). + # Multiple paths are tried in order; the first non-null value wins. + # ch: |- + # 配置从 API 响应中提取 Token 用量的 JSON 路径。路径为相对于响应顶层 JSON 的点分格式 + # (如 "usage.prompt_tokens" 解析为 json["usage"]["prompt_tokens"])。 + # 多个路径按顺序尝试,取第一个非空值。 + usage_field_paths: + # type: string[] + # name: + # en: Input Tokens + # ch: 输入 Token 字段路径 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: JSON paths to read the input (prompt) token count, tried in order. + # ch: 按顺序尝试的 JSON 路径,用于读取输入(prompt)Token 数量。 + input_tokens: + - usage.prompt_tokens + # type: string[] + # name: + # en: Output Tokens + # ch: 输出 Token 字段路径 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: JSON paths to read the output (completion) token count, tried in order. + # ch: 按顺序尝试的 JSON 路径,用于读取输出(completion)Token 数量。 + output_tokens: + - usage.completion_tokens + # type: string[] + # name: + # en: Total Tokens + # ch: 总 Token 字段路径 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # JSON paths to read the total token count, tried in order. + # If none match, total is computed as input_tokens + output_tokens. + # ch: |- + # 按顺序尝试的 JSON 路径,用于读取总 Token 数量。 + # 若均未命中,则自动计算为 input_tokens + output_tokens。 + total_tokens: + - usage.total_tokens + # type: string[] + # name: + # en: Cached Tokens + # ch: 缓存 Token 字段路径 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # JSON paths to read the cached (prompt cache hit) token count, tried in order. + # usage.prompt_tokens_details.cached_tokens covers the OpenAI API; + # usage.cache_read_input_tokens covers the Anthropic API. + # If none match, the llm_cached_tokens metric is omitted. + # ch: |- + # 按顺序尝试的 JSON 路径,用于读取缓存(Prompt Cache 命中)Token 数量。 + # usage.prompt_tokens_details.cached_tokens 对应 OpenAI API; + # usage.cache_read_input_tokens 对应 Anthropic API。 + # 若均未命中,则不输出 llm_cached_tokens 指标。 + cached_tokens: + - usage.prompt_tokens_details.cached_tokens + - usage.cache_read_input_tokens + # type: section + # name: + # en: Business Dimension Extractors + # ch: 业务维度提取配置 + # description: + # en: |- + # Configure how to extract org_path, user_id, and app_id from request + # headers or JSON body fields. Extraction uses a first-match-wins strategy: + # for each dimension, headers are tried in listed order first; if none match, + # json_paths are tried in listed order. The first non-empty value found stops + # further attempts for that dimension. + # ch: |- + # 配置从请求头或 JSON Body 字段提取 org_path、user_id、app_id 的规则。 + # 每个维度采用"首个命中即停止"策略:先按顺序尝试 headers,若均未命中则 + # 按顺序尝试 json_paths,找到第一个非空值后不再继续后续候选项。 + biz_dimension_extractors: + # type: section + # name: + # en: Org Path + # ch: 组织架构路径 + # description: + # en: Rules for extracting the organization/department path dimension. + # ch: 提取组织架构路径维度的规则。 + org_path: + # type: string[] + # name: + # en: Headers + # ch: 请求头候选列表 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: HTTP request header names to try, in order, for org_path extraction. + # ch: 按顺序尝试的 HTTP 请求头名称,用于提取 org_path。 + headers: + - x-org-path + # type: string[] + # name: + # en: JSON Paths + # ch: JSON 字段路径候选列表 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # JSON body field paths to try, in order, for org_path extraction. + # Supports one level of dot notation, e.g. "metadata.org_path". + # ch: |- + # 按顺序尝试的 JSON Body 字段路径,用于提取 org_path。 + # 支持一级点号路径,如 "metadata.org_path"。 + json_paths: [] + # type: section + # name: + # en: User ID + # ch: 用户标识 + # description: + # en: Rules for extracting the business user identifier dimension. + # ch: 提取业务用户标识维度的规则。 + user_id: + # type: string[] + # name: + # en: Headers + # ch: 请求头候选列表 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: HTTP request header names to try, in order, for user_id extraction. + # ch: 按顺序尝试的 HTTP 请求头名称,用于提取 user_id。 + headers: + - x-user-id + # type: string[] + # name: + # en: JSON Paths + # ch: JSON 字段路径候选列表 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # JSON body field paths to try, in order, for user_id extraction. + # Supports one level of dot notation, e.g. "metadata.user_id". + # ch: |- + # 按顺序尝试的 JSON Body 字段路径,用于提取 user_id。 + # 支持一级点号路径,如 "metadata.user_id"。 + json_paths: [] + # type: section + # name: + # en: App ID + # ch: 应用标识 + # description: + # en: Rules for extracting the application identifier dimension. + # ch: 提取应用标识维度的规则。 + app_id: + # type: string[] + # name: + # en: Headers + # ch: 请求头候选列表 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: HTTP request header names to try, in order, for app_id extraction. + # ch: 按顺序尝试的 HTTP 请求头名称,用于提取 app_id。 + headers: + - appid + # type: string[] + # name: + # en: JSON Paths + # ch: JSON 字段路径候选列表 + # unit: + # range: [] + # enum_options: [] + # modification: agent_restart + # ee_feature: false + # description: + # en: |- + # JSON body field paths to try, in order, for app_id extraction. + # Supports one level of dot notation, e.g. "metadata.app_id". + # ch: |- + # 按顺序尝试的 JSON Body 字段路径,用于提取 app_id。 + # 支持一级点号路径,如 "metadata.app_id"。 + json_paths: [] # type: dict # name: # en: Custom Protocol Parsing