mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(plugins): preserve request origin and raw upstream observations (#497)
* fix(plugins): preserve request origin and raw upstream observations Co-authored-by: Codex <noreply@openai.com> * perf(plugins): share immutable response observations Avoid copying response metadata and header collections for every stream event. Update Workbench byte headers and virtual-time timeout coverage, and align the large-request fixture with RPC v5. Co-authored-by: Codex <noreply@openai.com> --------- Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1582,6 +1582,12 @@ where
|
||||
.attempt(self.attempts)
|
||||
.record_provider_failure(&error);
|
||||
let mut atomic_client_events = error.take_atomic_client_events();
|
||||
// 失败随附的观测也属于当前 attempt,换号记录不能等到最终交付才读取
|
||||
for event in &atomic_client_events {
|
||||
if let Some(observation) = event.response_observation() {
|
||||
self.observe_response(observation.clone());
|
||||
}
|
||||
}
|
||||
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if self.request_observation.is_some() {
|
||||
let websocket_attempt = websocket_observation_attempt(¤t);
|
||||
|
||||
@@ -65,6 +65,22 @@ pub struct ExecutionRequestMetadata {
|
||||
pub previous_response_id: Option<PreviousResponseId>,
|
||||
}
|
||||
|
||||
impl ExecutionRequestMetadata {
|
||||
pub(super) fn inherit_request_origin(&mut self, scope: &ExtensionCallScope) {
|
||||
let Some(request) = scope.request() else {
|
||||
return;
|
||||
};
|
||||
// 执行入口可以是内部调用,客户端来源仍属于原始入口快照
|
||||
self.client_ip = request.client_ip;
|
||||
self.user_agent = request
|
||||
.headers
|
||||
.iter()
|
||||
.find(|header| header.name().eq_ignore_ascii_case("user-agent"))
|
||||
.and_then(|header| std::str::from_utf8(header.value()).ok())
|
||||
.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthenticatedClient {
|
||||
pub(super) settings: Option<RequestSettings>,
|
||||
|
||||
@@ -1001,8 +1001,7 @@ impl DefaultExecutionService {
|
||||
let execution_effects_baseline = parent.graph.effects.epoch();
|
||||
metadata.endpoint = "host.model".to_owned();
|
||||
metadata.transport = ClientTransport::InternalPlugin;
|
||||
metadata.client_ip = None;
|
||||
metadata.user_agent = None;
|
||||
metadata.inherit_request_origin(&extension_scope);
|
||||
let authorization = AuthorizedExecution {
|
||||
account_scope,
|
||||
response_control: None,
|
||||
@@ -1155,8 +1154,7 @@ impl DefaultExecutionService {
|
||||
let execution_effects_baseline = graph.effects.epoch();
|
||||
metadata.endpoint = "host.model".to_owned();
|
||||
metadata.transport = ClientTransport::InternalPlugin;
|
||||
metadata.client_ip = None;
|
||||
metadata.user_agent = None;
|
||||
metadata.inherit_request_origin(&authority.extension_scope);
|
||||
self.client_api_key_usage
|
||||
.record_used(authority.client.policy.key_id());
|
||||
self.start_authorized(
|
||||
|
||||
@@ -547,7 +547,7 @@ pub struct ProviderResponseTimings {
|
||||
/// 这样 Provider 专有协议不会渗入路由
|
||||
/// 或管理领域
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResponseMetadata(String);
|
||||
pub struct ProviderResponseMetadata(Arc<str>);
|
||||
|
||||
impl ProviderResponseMetadata {
|
||||
const MAX_BYTES: usize = 32 * 1024;
|
||||
@@ -561,7 +561,7 @@ impl ProviderResponseMetadata {
|
||||
serde_json::from_str::<Value>(&json)
|
||||
.ok()
|
||||
.filter(Value::is_object)
|
||||
.map(|_| Self(json))
|
||||
.map(|_| Self(json.into()))
|
||||
}
|
||||
|
||||
/// 返回不透明的 JSON 文本,供持久化 adapter 原样验证和写入
|
||||
@@ -626,7 +626,8 @@ pub struct ProviderResponseObservation {
|
||||
service_tier: Option<String>,
|
||||
upstream_response_model: Option<String>,
|
||||
timings: ProviderResponseTimings,
|
||||
client_headers: Vec<ProviderResponseHeader>,
|
||||
// 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合
|
||||
client_headers: Arc<[ProviderResponseHeader]>,
|
||||
provider_metadata: Option<ProviderResponseMetadata>,
|
||||
}
|
||||
|
||||
@@ -642,7 +643,7 @@ impl ProviderResponseObservation {
|
||||
service_tier: None,
|
||||
upstream_response_model: None,
|
||||
timings: ProviderResponseTimings::default(),
|
||||
client_headers: Vec::new(),
|
||||
client_headers: Arc::default(),
|
||||
provider_metadata: None,
|
||||
}
|
||||
}
|
||||
@@ -719,7 +720,7 @@ impl ProviderResponseObservation {
|
||||
|
||||
#[must_use]
|
||||
pub fn with_client_headers(mut self, client_headers: Vec<ProviderResponseHeader>) -> Self {
|
||||
self.client_headers = client_headers;
|
||||
self.client_headers = client_headers.into();
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -3467,13 +3467,16 @@ fn explicit_429_after_structural_event_should_retry_before_commit() {
|
||||
fn atomic_response_failed_should_rotate_account_before_stream_commit() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut failure = atomic_response_failed("response-first-failed", "first account failure");
|
||||
let mut events = failure.take_atomic_client_events();
|
||||
events[0].attach_observation(
|
||||
ProviderResponseObservation::new(UpstreamTransport::new("http_sse").unwrap())
|
||||
.with_request_id(OpaqueUpstreamValue::new("upstream-failed-attempt")),
|
||||
);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(atomic_response_failed(
|
||||
"response-first-failed",
|
||||
"first account failure",
|
||||
))],
|
||||
items: vec![Err(failure.with_atomic_client_events(events))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_second",
|
||||
@@ -3516,6 +3519,10 @@ fn atomic_response_failed_should_rotate_account_before_stream_commit() {
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.commits, 1);
|
||||
assert_eq!(state.intermediate_failures, 1);
|
||||
assert_eq!(
|
||||
state.intermediate_request_ids,
|
||||
[Some("upstream-failed-attempt".to_owned())]
|
||||
);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
|
||||
@@ -1195,6 +1195,114 @@ fn request_policy_snapshot(
|
||||
.with_extensions(Some(generation))
|
||||
}
|
||||
|
||||
fn plugin_origin_scope() -> gateway_core::engine::extensions::ExtensionCallScope {
|
||||
gateway_core::engine::extensions::ExtensionCallScope::default().with_request(Arc::new(
|
||||
gateway_core::engine::extensions::RequestContext {
|
||||
method: "POST".into(),
|
||||
uri: "/plugin/proxy".into(),
|
||||
http_version: "HTTP/1.1".into(),
|
||||
headers: vec![gateway_core::engine::middleware::MiddlewareHeader::new(
|
||||
"User-Agent",
|
||||
Bytes::from_static(b"fixture-client/494"),
|
||||
)],
|
||||
peer_addr: None,
|
||||
client_ip: Some("192.0.2.49".parse().unwrap()),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_origin_unknown() {
|
||||
use gateway_core::engine::nested::{
|
||||
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
|
||||
};
|
||||
block_on(async {
|
||||
for has_origin in [true, false] {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(request_policy_snapshot(
|
||||
super::extensions::reference("origin-fixture"),
|
||||
&[ProviderKind::new("openai").unwrap()],
|
||||
)),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([Arc::new(ProcessingProvider) as Arc<dyn Provider>]).unwrap(),
|
||||
Arc::new(Admissions::default()),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(RecordingClientApiKeyUsage::default()),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let input = request(&service, ClientTransport::InternalPlugin);
|
||||
let context = NestedModelExecutionPort::bind(
|
||||
&service,
|
||||
BoundModelExecutionBinding {
|
||||
settings: None,
|
||||
client_key_id: ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
initiating_plugin_instance_id: "origin-fixture".into(),
|
||||
cancellation: Default::default(),
|
||||
extension_scope: if has_origin {
|
||||
plugin_origin_scope()
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut started = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
context: context.clone(),
|
||||
public_model: input.public_model,
|
||||
operation: input.operation,
|
||||
metadata: input.metadata,
|
||||
provider: None,
|
||||
account: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut child = NestedModelExecutionPort::start(
|
||||
&service,
|
||||
NestedModelExecutionRequest {
|
||||
parent_request_id: started.request_id.clone(),
|
||||
initiating_plugin_instance_id: "nested-origin-fixture".into(),
|
||||
public_model: PublicModelId::new("gpt-start").unwrap(),
|
||||
operation: start_operation(),
|
||||
metadata: request(&service, ClientTransport::InternalPlugin).metadata,
|
||||
provider: None,
|
||||
account: None,
|
||||
parent_account: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
child.session.collect_uncommitted().await.unwrap();
|
||||
child.session.commit_downstream(Some(200)).await.unwrap();
|
||||
child.session.detach_finalize().await;
|
||||
started.session.collect_uncommitted().await.unwrap();
|
||||
{
|
||||
let requests = store.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in requests.iter() {
|
||||
assert_eq!(
|
||||
request.client_ip,
|
||||
has_origin.then(|| "192.0.2.49".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
request.user_agent.as_deref(),
|
||||
has_origin.then_some("fixture-client/494")
|
||||
);
|
||||
assert_eq!(
|
||||
request.client_transport,
|
||||
ClientTransport::InternalPlugin.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
started.session.detach_finalize().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_parent_rejects_new_nested_execution_and_cancels_an_active_child() {
|
||||
block_on(async {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
use bytes::Bytes;
|
||||
use gateway_core::event::{
|
||||
ContentItem, ContentKind, EventSequenceError, EventSequenceValidator, GatewayEvent,
|
||||
ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
|
||||
ReasoningDelta, ResponseMeta, TextDelta, ToolCallDelta,
|
||||
ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseMetadata,
|
||||
ProviderResponseObservation, ProviderResponseTimings, ReasoningDelta, ResponseMeta, TextDelta,
|
||||
ToolCallDelta,
|
||||
};
|
||||
use gateway_core::upstream::UpstreamTransport;
|
||||
use serde_json::json;
|
||||
@@ -335,3 +336,61 @@ fn validator_should_accept_reasoning_and_tool_call_stream() {
|
||||
|
||||
assert_eq!(validator.finish(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_observation_snapshots_preserve_values_without_copying_large_payloads() {
|
||||
let metadata = format!("{{\"raw\":\"{}\"}}", "a".repeat(32 * 1024 - 10));
|
||||
let observation =
|
||||
ProviderResponseObservation::new(UpstreamTransport::new("http_sse").expect("transport"))
|
||||
.with_provider_metadata(
|
||||
ProviderResponseMetadata::new(metadata.clone()).expect("metadata"),
|
||||
)
|
||||
.with_client_headers(
|
||||
(0..32)
|
||||
.map(|_| {
|
||||
ProviderResponseHeader::new("set-cookie", Bytes::from_static(b"raw-\xff"))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.with_timings(ProviderResponseTimings {
|
||||
first_event_ms: Some(17),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
|
||||
let allocations = allocation_counter::measure(|| {
|
||||
for _ in 0..1_000 {
|
||||
std::hint::black_box(observation.clone());
|
||||
}
|
||||
});
|
||||
// 允许小标量字段的复制,但流式快照不能随大头集合和 metadata 重复分配
|
||||
assert!(allocations.bytes_total < 256 * 1_000, "{allocations:?}");
|
||||
assert_eq!(allocations.bytes_current, 0);
|
||||
|
||||
let updated = observation
|
||||
.clone()
|
||||
.with_client_headers(Vec::new())
|
||||
.with_provider_metadata(
|
||||
ProviderResponseMetadata::new("{\"updated\":true}".to_owned()).expect("metadata"),
|
||||
)
|
||||
.with_timings(ProviderResponseTimings {
|
||||
first_event_ms: Some(23),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
assert_eq!(
|
||||
observation.provider_metadata().expect("metadata").as_json(),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(observation.client_headers().len(), 32);
|
||||
assert!(
|
||||
observation.client_headers().iter().all(|header| {
|
||||
header.name() == "set-cookie" && header.value().as_ref() == b"raw-\xff"
|
||||
})
|
||||
);
|
||||
assert_eq!(observation.timings().first_event_ms, Some(17));
|
||||
assert_eq!(
|
||||
updated.provider_metadata().expect("metadata").as_json(),
|
||||
"{\"updated\":true}"
|
||||
);
|
||||
assert!(updated.client_headers().is_empty());
|
||||
assert_eq!(updated.timings().first_event_ms, Some(23));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ pub struct HttpRequest {
|
||||
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub version: http::Version,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
pub body: HttpBody,
|
||||
}
|
||||
@@ -53,6 +54,7 @@ pub struct HttpResponse {
|
||||
pub struct HttpBody {
|
||||
response: hyper::body::Incoming,
|
||||
pending: Bytes,
|
||||
trailers: Vec<(String, Vec<u8>)>,
|
||||
received: usize,
|
||||
deadline: Instant,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
@@ -66,7 +68,8 @@ pub struct HttpError {
|
||||
pub send_state: UpstreamSendState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HttpErrorKind {
|
||||
InvalidRequest,
|
||||
AddressDenied,
|
||||
@@ -366,10 +369,12 @@ impl PendingResponse {
|
||||
pub(super) fn into_http(self) -> HttpResponse {
|
||||
HttpResponse {
|
||||
status: self.status,
|
||||
version: self.response.version(),
|
||||
headers: self.headers,
|
||||
body: HttpBody {
|
||||
response: self.response.into_body(),
|
||||
pending: Bytes::new(),
|
||||
trailers: Vec::new(),
|
||||
received: 0,
|
||||
deadline: self.deadline,
|
||||
_permit: self.permit,
|
||||
@@ -379,6 +384,11 @@ impl PendingResponse {
|
||||
}
|
||||
|
||||
impl HttpBody {
|
||||
/// 取出截至当前读取位置收到的尾部头,保留多值与原始字节
|
||||
pub fn take_trailers(&mut self) -> Vec<(String, Vec<u8>)> {
|
||||
std::mem::take(&mut self.trailers)
|
||||
}
|
||||
|
||||
pub async fn read(&mut self, maximum: usize) -> Result<Option<Bytes>, HttpError> {
|
||||
self.read_until(maximum, self.deadline).await
|
||||
}
|
||||
@@ -414,7 +424,15 @@ impl HttpBody {
|
||||
self.pending = match tokio::time::timeout_at(deadline, self.response.frame()).await {
|
||||
Ok(Some(Ok(frame))) => match frame.into_data() {
|
||||
Ok(chunk) => chunk,
|
||||
Err(_) => continue,
|
||||
Err(frame) => {
|
||||
let trailers = frame
|
||||
.into_trailers()
|
||||
.map_err(|_| HttpError::sent("response frame"))?;
|
||||
self.trailers.extend(trailers.iter().map(|(name, value)| {
|
||||
(name.as_str().to_owned(), value.as_bytes().to_vec())
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Ok(None) => return Ok(None),
|
||||
Ok(Some(Err(_))) => return Err(HttpError::sent("response body")),
|
||||
|
||||
@@ -27,6 +27,8 @@ type Socket = WebSocketStream<TokioIo<hyper::upgrade::Upgraded>>;
|
||||
|
||||
/// 读写分别串行,允许等待输出时发送控制消息;关闭会唤醒在途操作并释放容量
|
||||
pub struct ManagedWebSocket {
|
||||
handshake_version: http::Version,
|
||||
handshake_headers: Vec<(String, Vec<u8>)>,
|
||||
writer: Mutex<Option<(SplitSink<Socket, Message>, OwnedSemaphorePermit)>>,
|
||||
reader: Mutex<Option<SplitStream<Socket>>>,
|
||||
closed: watch::Sender<bool>,
|
||||
@@ -39,6 +41,7 @@ pub enum WebSocketMessage {
|
||||
|
||||
pub struct WebSocketResponse {
|
||||
pub status: u16,
|
||||
pub version: http::Version,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
pub connection: Option<ManagedWebSocket>,
|
||||
pub body: Option<HttpBody>,
|
||||
@@ -70,6 +73,7 @@ impl HttpClient {
|
||||
let response = response.into_http();
|
||||
return Ok(WebSocketResponse {
|
||||
status: response.status,
|
||||
version: response.version,
|
||||
headers: response.headers,
|
||||
connection: None,
|
||||
body: Some(response.body),
|
||||
@@ -112,8 +116,11 @@ impl HttpClient {
|
||||
let (writer, reader) = socket.split();
|
||||
Ok(WebSocketResponse {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
version: response.response.version(),
|
||||
headers: response.headers.clone(),
|
||||
connection: Some(ManagedWebSocket {
|
||||
handshake_version: response.response.version(),
|
||||
handshake_headers: response.headers,
|
||||
writer: Mutex::new(Some((writer, response.permit))),
|
||||
reader: Mutex::new(Some(reader)),
|
||||
closed: watch::channel(false).0,
|
||||
@@ -124,6 +131,17 @@ impl HttpClient {
|
||||
}
|
||||
|
||||
impl ManagedWebSocket {
|
||||
/// 连接复用仍可读取最初握手的原始元数据,不伪造一次新握手
|
||||
#[must_use]
|
||||
pub const fn handshake_version(&self) -> http::Version {
|
||||
self.handshake_version
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handshake_headers(&self) -> &[(String, Vec<u8>)] {
|
||||
&self.handshake_headers
|
||||
}
|
||||
|
||||
pub async fn send(
|
||||
&self,
|
||||
message: WebSocketMessage,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [4],
|
||||
"protocol_versions": [5],
|
||||
"capabilities": [
|
||||
{ "capability": "frontend_authentication", "versions": [2] },
|
||||
{ "capability": "scheduler", "versions": [3] },
|
||||
@@ -9,7 +9,7 @@
|
||||
{ "capability": "model_catalog", "versions": [1] },
|
||||
{ "capability": "retry_policy", "versions": [2] },
|
||||
{ "capability": "middleware", "versions": [5] },
|
||||
{ "capability": "upstream_adapter", "versions": [3] },
|
||||
{ "capability": "upstream_adapter", "versions": [4] },
|
||||
{ "capability": "observer", "versions": [2] },
|
||||
{ "capability": "command_line", "versions": [1] },
|
||||
{ "capability": "management", "versions": [2] },
|
||||
|
||||
@@ -24,7 +24,7 @@ use gateway_protocol::openai::sse::SseEventDecoder;
|
||||
pub(super) struct EventDecoder {
|
||||
sequence: EventSequenceValidator,
|
||||
response_id: Option<String>,
|
||||
service_tier: Option<String>,
|
||||
observation: Option<ProviderResponseObservation>,
|
||||
usage: Usage,
|
||||
contents: usize,
|
||||
pub(super) completed: bool,
|
||||
@@ -54,6 +54,7 @@ impl EventDecoder {
|
||||
transport: &str,
|
||||
account: &dyn UpstreamAccountConnection,
|
||||
sent: UpstreamSendState,
|
||||
started_at: std::time::Instant,
|
||||
) -> Result<DecodedEvent, EventDecodeError> {
|
||||
let message = UpstreamAdapterEvent::decode(payload)
|
||||
.map_err(|source| invalid(sent).with_source(source))?;
|
||||
@@ -65,22 +66,31 @@ impl EventDecoder {
|
||||
.wire
|
||||
.map(|wire| decode_wire(wire, protocol, sent))
|
||||
.transpose()?;
|
||||
let has_observation = self.observation.is_some() || message.observation.is_some();
|
||||
let mut observation = match self.observation.take() {
|
||||
Some(observation) => observation,
|
||||
None => ProviderResponseObservation::new(
|
||||
UpstreamTransport::new(transport)
|
||||
.map_err(|source| invalid(sent).with_source(source))?,
|
||||
),
|
||||
};
|
||||
if let Some(update) = message.observation {
|
||||
observation = super::observation::merge(observation, update, sent)?;
|
||||
}
|
||||
if let Some(failure) = message.failure {
|
||||
if !message.event.facts.is_empty() || message.continuation.is_some() {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
let mut error = failure_error(failure, sent)?;
|
||||
let mut events = Vec::new();
|
||||
if has_observation {
|
||||
events.push(ProviderEvent::observation(observation));
|
||||
}
|
||||
if let Some(wire) = wire {
|
||||
error = error.with_atomic_client_events(vec![ProviderEvent::wire(wire)]);
|
||||
events.push(ProviderEvent::wire(wire));
|
||||
}
|
||||
return Err(EventDecodeError::Upstream(error));
|
||||
}
|
||||
if let Some(tier) = message.service_tier {
|
||||
if tier.is_empty() || tier.len() > 64 || tier.chars().any(char::is_control) {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
// 上游可能在完成时才把 auto 解析为实际档位;与原生观测一样保留最新值
|
||||
self.service_tier = Some(tier);
|
||||
return Err(EventDecodeError::Upstream(
|
||||
failure_error(failure, sent)?.with_atomic_client_events(events),
|
||||
));
|
||||
}
|
||||
let mut facts = Vec::new();
|
||||
for fact in message.event.facts {
|
||||
@@ -181,7 +191,7 @@ impl EventDecoder {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
if let Some(cost) =
|
||||
account.calculate_cost(self.service_tier.as_deref(), &self.usage)
|
||||
account.calculate_cost(observation.service_tier(), &self.usage)
|
||||
{
|
||||
let cost = GatewayEvent::CalculatedCost(cost);
|
||||
self.sequence
|
||||
@@ -213,22 +223,26 @@ impl EventDecoder {
|
||||
if message.continuation.is_some() && !self.completed {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
observation =
|
||||
super::observation::observe_output(observation, &facts, wire.is_some(), started_at);
|
||||
self.observation = Some(observation.clone());
|
||||
let mut event = if let Some(wire) = wire {
|
||||
ProviderEvent::canonical_with_wire(facts, wire)
|
||||
} else {
|
||||
let mut facts = facts.into_iter();
|
||||
let first = facts.next().ok_or_else(|| invalid(sent))?;
|
||||
facts.fold(ProviderEvent::canonical(first), ProviderEvent::with_fact)
|
||||
match facts.next() {
|
||||
Some(first) => {
|
||||
facts.fold(ProviderEvent::canonical(first), ProviderEvent::with_fact)
|
||||
}
|
||||
None => {
|
||||
return Ok(DecodedEvent {
|
||||
event: ProviderEvent::observation(observation),
|
||||
continuation: message.continuation,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(tier) = &self.service_tier {
|
||||
let observation = ProviderResponseObservation::new(
|
||||
UpstreamTransport::new(transport)
|
||||
.map_err(|source| invalid(sent).with_source(source))?,
|
||||
)
|
||||
.try_with_service_tier(tier.clone())
|
||||
.map_err(|source| invalid(sent).with_source(source))?;
|
||||
event.attach_observation(observation);
|
||||
}
|
||||
event.attach_observation(observation);
|
||||
Ok(DecodedEvent {
|
||||
event,
|
||||
continuation: message.continuation,
|
||||
@@ -308,6 +322,20 @@ fn decode_wire(
|
||||
ProtocolWireEvent::json_with_sse_metadata(protocol, event, data, id, retry)
|
||||
}
|
||||
}
|
||||
WirePayload::WebSocket {
|
||||
event,
|
||||
data,
|
||||
message,
|
||||
} => {
|
||||
let message =
|
||||
String::from_utf8(message).map_err(|source| invalid(sent).with_source(source))?;
|
||||
if let Some(data) = data {
|
||||
ProtocolWireEvent::json(protocol, event, data)
|
||||
.map(|wire| wire.with_raw_websocket_message(message))
|
||||
} else {
|
||||
ProtocolWireEvent::raw_websocket(protocol, message)
|
||||
}
|
||||
}
|
||||
WirePayload::RawSse { frame } => {
|
||||
single_sse(&frame, sent)?;
|
||||
ProtocolWireEvent::raw_sse(protocol, Bytes::from(frame))
|
||||
|
||||
@@ -106,6 +106,7 @@ impl Execution {
|
||||
self.adapter.declaration.transport.as_str(),
|
||||
self.invocation.account.as_ref(),
|
||||
active.managed.send_state.get(),
|
||||
self.invocation.context.timing_started_at(),
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
EventDecodeError::Invalid(error) => {
|
||||
@@ -131,7 +132,7 @@ impl Execution {
|
||||
}.await;
|
||||
if let Err(error) = terminal {
|
||||
// 尾部故障不得交付成功 wire,但已经解析的计量事实仍归 Core 结算
|
||||
let metering = decoded
|
||||
let mut metering: Vec<_> = decoded
|
||||
.event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
@@ -145,6 +146,11 @@ impl Execution {
|
||||
.cloned()
|
||||
.map(gateway_core::event::ProviderEvent::canonical)
|
||||
.collect();
|
||||
if let Some(observation) = decoded.event.response_observation() {
|
||||
metering.push(gateway_core::event::ProviderEvent::observation(
|
||||
observation.clone(),
|
||||
));
|
||||
}
|
||||
return Err(error.with_atomic_client_events(metering));
|
||||
}
|
||||
self.ended = true;
|
||||
@@ -243,6 +249,10 @@ impl Execution {
|
||||
protocol: invocation.operation.protocol().to_owned(),
|
||||
client_transport: invocation.context.client_transport().as_str().to_owned(),
|
||||
fast_mode: invocation.context.fast_mode().as_str().to_owned(),
|
||||
request_elapsed_ms: u64::try_from(
|
||||
invocation.context.timing_started_at().elapsed().as_millis(),
|
||||
)
|
||||
.unwrap_or(u64::MAX),
|
||||
headers: invocation
|
||||
.headers
|
||||
.iter()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod event;
|
||||
mod execution;
|
||||
mod failure;
|
||||
mod observation;
|
||||
mod registration;
|
||||
pub(crate) mod target;
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 合并适配器的原始上游观测,并记录宿主实际收到规范化输出的时间
|
||||
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProviderResponseHeader, ProviderResponseMetadata, ProviderResponseObservation,
|
||||
ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
};
|
||||
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
|
||||
use gateway_plugin_sdk::call::model::facts::ResponseObservation;
|
||||
|
||||
use super::failure::invalid;
|
||||
|
||||
pub(super) fn merge(
|
||||
mut current: ProviderResponseObservation,
|
||||
update: ResponseObservation,
|
||||
sent: UpstreamSendState,
|
||||
) -> Result<ProviderResponseObservation, gateway_core::error::ProviderError> {
|
||||
// 传输类型由已选适配器固定,不能让不一致的报告绕过 Core 的观测归属
|
||||
if update.transport != current.transport().as_str() {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
if let Some(value) = update.http_version {
|
||||
let version = if value == "unknown" {
|
||||
UpstreamHttpVersion::Unknown
|
||||
} else {
|
||||
UpstreamHttpVersion::parse(&value).ok_or_else(|| invalid(sent))?
|
||||
};
|
||||
current = current.with_http_version(version);
|
||||
}
|
||||
if let Some(value) = update.websocket_pool {
|
||||
current = current.with_websocket_pool(match value.as_str() {
|
||||
"new" => WebSocketPoolKind::New,
|
||||
"reuse" => WebSocketPoolKind::Reuse,
|
||||
_ => return Err(invalid(sent)),
|
||||
});
|
||||
}
|
||||
if let Some(value) = update.status_code {
|
||||
if !(100..=599).contains(&value) {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
current = current.with_status_code(value);
|
||||
}
|
||||
if let Some(value) = update.request_id {
|
||||
current = current.with_request_id(OpaqueUpstreamValue::new(value));
|
||||
}
|
||||
if let Some(value) = update.service_tier {
|
||||
current = current
|
||||
.try_with_service_tier(value)
|
||||
.map_err(|source| invalid(sent).with_source(source))?;
|
||||
}
|
||||
if let Some(value) = update.upstream_response_model {
|
||||
current = current.with_upstream_response_model_if_valid(&value);
|
||||
if current.upstream_response_model() != Some(value.trim()) {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
}
|
||||
if !update.client_headers.is_empty() {
|
||||
current = current.with_client_headers(
|
||||
update
|
||||
.client_headers
|
||||
.into_iter()
|
||||
.map(|header| ProviderResponseHeader::new(header.name, header.value.into()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(value) = update.provider_metadata {
|
||||
current = current.with_provider_metadata(
|
||||
ProviderResponseMetadata::new(value).ok_or_else(|| invalid(sent))?,
|
||||
);
|
||||
}
|
||||
let previous = current.timings();
|
||||
let update = update.timings;
|
||||
let timings = ProviderResponseTimings {
|
||||
transport_decision_wait_ms: update
|
||||
.transport_decision_wait_ms
|
||||
.or(previous.transport_decision_wait_ms),
|
||||
connect_ms: update.connect_ms.or(previous.connect_ms),
|
||||
headers_ms: update.headers_ms.or(previous.headers_ms),
|
||||
first_event_ms: update.first_event_ms.or(previous.first_event_ms),
|
||||
first_reasoning_ms: update.first_reasoning_ms.or(previous.first_reasoning_ms),
|
||||
first_text_ms: update.first_text_ms.or(previous.first_text_ms),
|
||||
first_token_ms: update.first_token_ms.or(previous.first_token_ms),
|
||||
provider_processing_ms: update
|
||||
.provider_processing_ms
|
||||
.or(previous.provider_processing_ms),
|
||||
upstream_response_ms: update
|
||||
.upstream_response_ms
|
||||
.or(previous.upstream_response_ms),
|
||||
upstream_api_overhead_ms: update
|
||||
.upstream_api_overhead_ms
|
||||
.or(previous.upstream_api_overhead_ms),
|
||||
upstream_engine_ms: update.upstream_engine_ms.or(previous.upstream_engine_ms),
|
||||
};
|
||||
Ok(current.with_timings(timings))
|
||||
}
|
||||
|
||||
pub(super) fn observe_output(
|
||||
current: ProviderResponseObservation,
|
||||
facts: &[GatewayEvent],
|
||||
has_wire: bool,
|
||||
started_at: std::time::Instant,
|
||||
) -> ProviderResponseObservation {
|
||||
let elapsed = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
let mut timings = current.timings();
|
||||
if has_wire || !facts.is_empty() {
|
||||
timings.first_event_ms.get_or_insert(elapsed);
|
||||
}
|
||||
// 内容边界来自插件报告的 canonical 事实,不按上游私有事件名猜测
|
||||
for fact in facts {
|
||||
let has_output = match fact {
|
||||
GatewayEvent::TextDelta(delta) if !delta.text.is_empty() => {
|
||||
timings.first_text_ms.get_or_insert(elapsed);
|
||||
true
|
||||
}
|
||||
GatewayEvent::ReasoningDelta(delta) if !delta.text.is_empty() => {
|
||||
timings.first_reasoning_ms.get_or_insert(elapsed);
|
||||
true
|
||||
}
|
||||
GatewayEvent::ToolCallDelta(delta) => {
|
||||
!delta.arguments_delta.is_empty()
|
||||
|| delta.name.as_ref().is_some_and(|name| !name.is_empty())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if has_output {
|
||||
timings.first_token_ms.get_or_insert(elapsed);
|
||||
}
|
||||
}
|
||||
current.with_timings(timings)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use gateway_plugin_sdk::{
|
||||
FinishReason as WireFinishReason, Usage as WireUsage, WireEvent, WirePayload,
|
||||
facts::{self, ExecutionCost, ExecutionFacts},
|
||||
},
|
||||
observation::{RequestMoney, RequestTimings},
|
||||
observation::RequestMoney,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,7 +120,7 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
|
||||
request_id: value.request_id().map(|value| value.as_str().to_owned()),
|
||||
service_tier: value.service_tier().map(str::to_owned),
|
||||
upstream_response_model: value.upstream_response_model().map(str::to_owned),
|
||||
timings: RequestTimings {
|
||||
timings: facts::ResponseTimings {
|
||||
transport_decision_wait_ms: timings.transport_decision_wait_ms,
|
||||
connect_ms: timings.connect_ms,
|
||||
headers_ms: timings.headers_ms,
|
||||
@@ -132,7 +132,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
|
||||
upstream_response_ms: timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: timings.upstream_engine_ms,
|
||||
latency_ms: None,
|
||||
},
|
||||
client_headers: value
|
||||
.client_headers()
|
||||
@@ -271,6 +270,12 @@ fn project_wire(wire: &ProtocolWireEvent) -> Result<WireEvent, PluginFault> {
|
||||
WirePayload::RawJson {
|
||||
body: body.to_vec(),
|
||||
}
|
||||
} else if let Some(message) = wire.raw_websocket_message() {
|
||||
WirePayload::WebSocket {
|
||||
event: wire.event_type().map(str::to_owned),
|
||||
data: wire.has_json_data().then(|| wire.data().clone()),
|
||||
message: message.as_bytes().to_vec(),
|
||||
}
|
||||
} else if wire.has_json_data() {
|
||||
WirePayload::Json {
|
||||
event: wire.event_type().map(str::to_owned),
|
||||
@@ -283,15 +288,6 @@ fn project_wire(wire: &ProtocolWireEvent) -> Result<WireEvent, PluginFault> {
|
||||
WirePayload::RawSse {
|
||||
frame: frame.to_vec(),
|
||||
}
|
||||
} else if let Some(message) = wire.raw_websocket_message() {
|
||||
// 子模型回调采用 SDK 的事件流表达,不把 WebSocket 文本冒充 HTTP body
|
||||
WirePayload::RawSse {
|
||||
frame: gateway_protocol::openai::sse::encode_sse_event(
|
||||
wire.event_type().unwrap_or_default(),
|
||||
message,
|
||||
)
|
||||
.into_bytes(),
|
||||
}
|
||||
} else {
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::Fault,
|
||||
|
||||
@@ -58,16 +58,12 @@ impl HttpCallbacks<'_> {
|
||||
);
|
||||
let response = response.map_err(http_error)?;
|
||||
let mut body = response.body;
|
||||
let headers = response
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| String::from_utf8(value).map(|value| (name, value)))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| sent_fault(ErrorCode::Fault, "HTTP header cannot be represented"))?;
|
||||
let mut result = wire::HttpResponse {
|
||||
status: response.status,
|
||||
headers,
|
||||
http_version: format!("{:?}", response.version),
|
||||
headers: response.headers,
|
||||
stream: None,
|
||||
trailers: Vec::new(),
|
||||
};
|
||||
let mut payload = Vec::new();
|
||||
if streaming {
|
||||
@@ -103,6 +99,7 @@ impl HttpCallbacks<'_> {
|
||||
}
|
||||
payload.extend_from_slice(&chunk);
|
||||
}
|
||||
result.trailers = body.take_trailers();
|
||||
}
|
||||
Ok(RpcReply {
|
||||
result: serde_json::to_value(result).map_err(|_| invalid())?,
|
||||
@@ -124,11 +121,7 @@ impl HttpCallbacks<'_> {
|
||||
HttpRequest {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name, value.into_bytes()))
|
||||
.collect(),
|
||||
headers: request.headers,
|
||||
maximum_body_bytes: self.body_limit(&payload)?,
|
||||
body: payload,
|
||||
},
|
||||
@@ -163,6 +156,9 @@ impl HttpCallbacks<'_> {
|
||||
_ = closed.wait_for(|closed| *closed) => Err(denied()),
|
||||
result = body.as_mut().ok_or_else(denied)?.read_with_timeout(request.maximum_bytes as usize, timeout) => result.map_err(http_error),
|
||||
};
|
||||
let trailers = body
|
||||
.as_mut()
|
||||
.map_or_else(Vec::new, |body| body.take_trailers());
|
||||
if !matches!(&result, Ok(Some(_))) {
|
||||
body.take();
|
||||
stream.closed.send_replace(true);
|
||||
@@ -174,7 +170,7 @@ impl HttpCallbacks<'_> {
|
||||
}
|
||||
let chunk = result?;
|
||||
Ok(RpcReply {
|
||||
result: serde_json::json!({"eof":chunk.is_none()}),
|
||||
result: serde_json::json!({"eof":chunk.is_none(), "trailers": trailers}),
|
||||
payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()),
|
||||
})
|
||||
}
|
||||
@@ -231,6 +227,10 @@ pub(super) fn http_error(error: HttpError) -> PluginFault {
|
||||
HttpErrorKind::Response => (ErrorCode::Upstream, "目标网站响应读取失败"),
|
||||
};
|
||||
let mut fault = PluginFault::new(code, message);
|
||||
fault.details = Some(serde_json::json!({
|
||||
"kind": error.kind(),
|
||||
"reason": error.reason(),
|
||||
}));
|
||||
fault.send_state = match error.send_state {
|
||||
UpstreamSendState::NotSent => SendState::NotSent,
|
||||
UpstreamSendState::Sent => SendState::Sent,
|
||||
|
||||
@@ -159,12 +159,7 @@ impl ManagedUpstream {
|
||||
(name, value.to_vec())
|
||||
})
|
||||
.collect();
|
||||
headers.extend(
|
||||
request
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name, value.into_bytes())),
|
||||
);
|
||||
headers.extend(request.headers);
|
||||
Ok((
|
||||
HttpRequest {
|
||||
method: request.method,
|
||||
@@ -237,7 +232,7 @@ pub(super) async fn dispatch(
|
||||
return Err(denied());
|
||||
}
|
||||
let mut socket = managed.socket.try_lock().map_err(|_| denied())?;
|
||||
if let Some((url, _)) = socket
|
||||
if let Some((url, connection)) = socket
|
||||
.as_ref()
|
||||
.filter(|(_, connection)| !connection.is_closed())
|
||||
{
|
||||
@@ -245,7 +240,13 @@ pub(super) async fn dispatch(
|
||||
return Err(denied());
|
||||
}
|
||||
return Ok(RpcReply {
|
||||
result: serde_json::json!({"status":101,"headers":[],"stream":null}),
|
||||
result: serde_json::json!({
|
||||
"status": 101,
|
||||
"http_version": format!("{:?}", connection.handshake_version()),
|
||||
"headers": connection.handshake_headers(),
|
||||
"stream": null,
|
||||
"trailers": [],
|
||||
}),
|
||||
payload: vec![],
|
||||
});
|
||||
}
|
||||
@@ -266,12 +267,8 @@ pub(super) async fn dispatch(
|
||||
.map_or_else(|error| error.send_state, |_| UpstreamSendState::Sent),
|
||||
);
|
||||
let mut response = response.map_err(super::http::http_error)?;
|
||||
let headers = response
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| String::from_utf8(value).map(|value| (name, value)))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| invalid())?;
|
||||
let headers = response.headers;
|
||||
let mut trailers = Vec::new();
|
||||
let mut payload = Vec::new();
|
||||
if let Some(body) = response.body.as_mut() {
|
||||
while let Some(chunk) = body
|
||||
@@ -284,6 +281,7 @@ pub(super) async fn dispatch(
|
||||
}
|
||||
payload.extend_from_slice(&chunk);
|
||||
}
|
||||
trailers = body.take_trailers();
|
||||
}
|
||||
if let Some(connection) = response.connection {
|
||||
*socket = Some((url, Arc::new(connection)));
|
||||
@@ -291,8 +289,10 @@ pub(super) async fn dispatch(
|
||||
Ok(RpcReply {
|
||||
result: serde_json::to_value(wire::HttpResponse {
|
||||
status: response.status,
|
||||
http_version: format!("{:?}", response.version),
|
||||
headers,
|
||||
stream: None,
|
||||
trailers,
|
||||
})
|
||||
.map_err(|_| invalid())?,
|
||||
payload,
|
||||
|
||||
@@ -102,7 +102,21 @@ async fn reading_websocket_facts_preserves_original_text_and_opaque_messages() {
|
||||
);
|
||||
}
|
||||
assert_eq!(delivered, messages);
|
||||
assert_eq!(marker_lines(&marker, 4).await.len(), 4);
|
||||
let snapshots = marker_lines(&marker, 4).await;
|
||||
for (snapshot, original) in snapshots[1..].iter().zip(&messages) {
|
||||
let event: gateway_plugin_sdk::call::model::ExecutionEvent =
|
||||
serde_json::from_value(snapshot.clone()).unwrap();
|
||||
let gateway_plugin_sdk::call::model::WirePayload::WebSocket { data, message, .. } =
|
||||
event.wire.unwrap().payload
|
||||
else {
|
||||
panic!("WebSocket facts must retain their original transport");
|
||||
};
|
||||
assert_eq!(message, original.as_bytes());
|
||||
assert_eq!(
|
||||
data.is_some(),
|
||||
serde_json::from_str::<serde_json::Value>(original).is_ok()
|
||||
);
|
||||
}
|
||||
drop(stream);
|
||||
runtime.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ impl UpstreamAccountConnection for Account {
|
||||
fn configuration(base: &str, streaming: bool) -> Value {
|
||||
json!({
|
||||
"upstream_registration":{"adapters":[{"id":"example","provider":"openai","base_url":format!("{base}/adapter/"),"paths":[{"path":"responses","purpose":"inference"}],"authentication_kinds":["oauth"],"transport":if streaming {"http_sse"} else {"http_json"},"protocol":"openai"}]},
|
||||
"upstream_callbacks":[{"method":if streaming {"host.upstream.http.do_stream"} else {"host.upstream.http.do"},"params":{"method":"POST","path":"responses","headers":[["content-type","application/json"]]},"body":"request"}],
|
||||
"upstream_callbacks":[{"method":if streaming {"host.upstream.http.do_stream"} else {"host.upstream.http.do"},"params":{"method":"POST","path":"responses","headers":[["content-type",b"application/json".to_vec()]]},"body":"request"}],
|
||||
"upstream_events":[
|
||||
{"event":{"facts":[{"type":"started","id":"response-one","model":"native-model"}]}},
|
||||
{"event":{"facts":[{"type":"usage","usage":{"input_tokens":7,"output_tokens":2}},{"type":"completed","id":"response-one","model":"native-model","reason":"stop"}]},"wire":if streaming {"sse"} else {"http"},"continuation":{"scope":"persisted","upstream_response_id":"private-upstream-id","state":{"cursor":"opaque"}}}
|
||||
@@ -457,6 +457,193 @@ async fn adapter_sse_outlives_rpc_budget_and_cancellation_closes_its_resources()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adapter_preserves_parsed_and_opaque_websocket_messages() {
|
||||
use gateway_plugin_sdk::call::model::{WireEvent, WirePayload};
|
||||
let messages = [
|
||||
"{ \"type\": \"future.event\", \"value\": 1e3 }".to_owned(),
|
||||
"opaque future message".to_owned(),
|
||||
format!("{{\"value\":{}0{}}}", "[".repeat(140), "]".repeat(140)),
|
||||
];
|
||||
let mut config = configuration("http://127.0.0.1:1", false);
|
||||
config["upstream_callbacks"] = json!([]);
|
||||
config["upstream_registration"]["adapters"][0]["transport"] = json!("websocket");
|
||||
let mut events = vec![config["upstream_events"][0].clone()];
|
||||
for message in &messages {
|
||||
events.push(json!({"event":{"facts":[],"wire":WireEvent {
|
||||
protocol: "openai".into(),
|
||||
payload: WirePayload::WebSocket {
|
||||
event: None, data: serde_json::from_str(message).ok(), message: message.as_bytes().to_vec(),
|
||||
},
|
||||
}}}));
|
||||
}
|
||||
events.push(json!({"event":config["upstream_events"][1]["event"].clone()}));
|
||||
config["upstream_events"] = json!(events);
|
||||
let (cache, _, runtime) = setup(config).await;
|
||||
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut stream = execute(&runtime, &generation, Account::new(), "key-one", None);
|
||||
let mut received = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Some(wire) = event.unwrap().wire_event() {
|
||||
assert!(wire.raw_sse_frame().is_none());
|
||||
received.push(wire.raw_websocket_message().unwrap().to_owned());
|
||||
}
|
||||
}
|
||||
assert_eq!(received, messages);
|
||||
drop(stream);
|
||||
drop(generation);
|
||||
runtime.shutdown().await;
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_http_failure_keeps_host_reason_and_error_kind() {
|
||||
let mut config = configuration("http://127.0.0.1:1", false);
|
||||
config["upstream_callbacks"][0]["params"]["method"] = json!("invalid method");
|
||||
let (cache, _, runtime) = setup(config).await;
|
||||
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut stream = execute(&runtime, &generation, Account::new(), "key-one", None);
|
||||
let error = stream.next().await.unwrap().unwrap_err();
|
||||
let raw: Value = serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap();
|
||||
assert_eq!(raw["fault"]["details"]["kind"], "invalid_request");
|
||||
assert_eq!(raw["fault"]["details"]["reason"], "method");
|
||||
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
|
||||
drop(stream);
|
||||
drop(generation);
|
||||
runtime.shutdown().await;
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adapter_preserves_observation_updates_and_measures_only_real_output() {
|
||||
let mut config = configuration("http://127.0.0.1:1", true);
|
||||
config["upstream_callbacks"] = json!([]);
|
||||
config["upstream_events"] = json!([
|
||||
{"event":{"facts":[{"type":"started","id":"response-one","model":"native-model"},
|
||||
{"type":"content_added","index":0,"kind":"reasoning"}]},
|
||||
"observation":{"transport":"http_sse","http_version":"HTTP/2","status_code":200,
|
||||
"request_id":"upstream-494","upstream_response_model":"real-model",
|
||||
"client_headers":[{"name":"x-bytes","value":[255]},{"name":"x-bytes","value":[254]}],
|
||||
"provider_metadata":"{ \"future\": 1e3 }","timings":{"connect_ms":5,"headers_ms":8}}},
|
||||
{"event":{"facts":[{"type":"reasoning_delta","index":0,"text":""}]}},
|
||||
{"event":{"facts":[{"type":"reasoning_delta","index":0,"text":"thinking"}]}},
|
||||
{"event":{"facts":[{"type":"content_added","index":1,"kind":"text"},{"type":"text_delta","index":1,"text":"answer"}]}},
|
||||
{"event":{"facts":[]},"observation":{"transport":"http_sse","service_tier":"priority",
|
||||
"timings":{"upstream_response_ms":1000,"upstream_api_overhead_ms":1.25,"upstream_engine_ms":998.75}}},
|
||||
{"event":{"facts":[{"type":"usage","usage":{"input_tokens":7,"output_tokens":2}},
|
||||
{"type":"completed","id":"response-one","model":"native-model","reason":"stop"}]},
|
||||
"observation":{"transport":"http_sse","timings":{"first_token_ms":41}}}
|
||||
]);
|
||||
let (cache, _, runtime) = setup(config).await;
|
||||
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let context = context(&runtime, &generation, "key-one");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let mut stream = execute_with_context(Account::new(), context, None);
|
||||
let mut observations = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
observations.push(event.unwrap().response_observation().unwrap().clone());
|
||||
}
|
||||
assert_eq!(observations.len(), 6);
|
||||
assert_eq!(observations[0].timings().first_token_ms, None);
|
||||
assert_eq!(observations[1].timings().first_token_ms, None);
|
||||
let first_output = observations[2].timings().first_token_ms.unwrap();
|
||||
assert!(first_output >= 50, "首字计时必须包括调用前的等待");
|
||||
assert_eq!(
|
||||
observations[2].timings().first_reasoning_ms,
|
||||
Some(first_output)
|
||||
);
|
||||
assert!(observations[3].timings().first_text_ms.unwrap() >= first_output);
|
||||
assert_eq!(observations[3].timings().upstream_response_ms, None);
|
||||
let last = observations.last().unwrap();
|
||||
assert_eq!(last.timings().first_token_ms, Some(41));
|
||||
assert_eq!(last.timings().connect_ms, Some(5));
|
||||
assert_eq!(last.timings().headers_ms, Some(8));
|
||||
assert_eq!(last.timings().upstream_response_ms, Some(1000));
|
||||
assert_eq!(last.timings().upstream_api_overhead_ms, Some(1.25));
|
||||
assert_eq!(last.timings().upstream_engine_ms, Some(998.75));
|
||||
assert_eq!(last.status_code(), Some(200));
|
||||
assert_eq!(last.http_version().unwrap().as_str(), "HTTP/2");
|
||||
assert_eq!(last.request_id().unwrap().as_str(), "upstream-494");
|
||||
assert_eq!(last.upstream_response_model(), Some("real-model"));
|
||||
assert_eq!(last.service_tier(), Some("priority"));
|
||||
assert_eq!(
|
||||
last.provider_metadata().unwrap().as_json(),
|
||||
"{ \"future\": 1e3 }"
|
||||
);
|
||||
assert_eq!(last.client_headers().len(), 2);
|
||||
assert_eq!(last.client_headers()[0].value().as_ref(), [255]);
|
||||
assert_eq!(last.client_headers()[1].value().as_ref(), [254]);
|
||||
drop(stream);
|
||||
drop(generation);
|
||||
runtime.shutdown().await;
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_http_preserves_byte_headers_trailers_and_version_for_plugins() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
for streaming in [false, true] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let upstream = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||
assert!(socket.read_buf(&mut request).await.unwrap() > 0);
|
||||
}
|
||||
socket.write_all(b"HTTP/1.1 200 OK\r\nX-Bytes: \xff\r\nX-Bytes: other\r\nTransfer-Encoding: chunked\r\nTrailer: x-tail\r\n\r\n").await.unwrap();
|
||||
let body = if streaming { "data: {}\n\n" } else { "{}" };
|
||||
socket
|
||||
.write_all(format!("{:x}\r\n{body}\r\n", body.len()).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
socket
|
||||
.write_all(b"0\r\nx-tail: \xfe\r\nx-tail: final\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let records = tempfile::tempdir().unwrap();
|
||||
let marker = records.path().join("http.jsonl");
|
||||
let mut config = configuration(&base, streaming);
|
||||
config["upstream_callbacks"][0]["body"] = json!("");
|
||||
config["upstream_http_marker"] = json!(marker);
|
||||
let (cache, _, runtime) = setup(config).await;
|
||||
let generation =
|
||||
ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut stream = execute(&runtime, &generation, Account::new(), "key-one", None);
|
||||
while let Some(event) = stream.next().await {
|
||||
event.unwrap();
|
||||
}
|
||||
upstream.await.unwrap();
|
||||
let observations: Vec<Value> = std::fs::read_to_string(&marker)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(observations[0]["http_version"], "HTTP/1.1");
|
||||
let headers = observations[0]["headers"].as_array().unwrap();
|
||||
assert!(headers.contains(&json!(["x-bytes", [255]])));
|
||||
assert!(headers.contains(&json!(["x-bytes", b"other".to_vec()])));
|
||||
let trailers = &observations.last().unwrap()["trailers"];
|
||||
assert_eq!(
|
||||
trailers,
|
||||
&json!([["x-tail", [254]], ["x-tail", b"final".to_vec()]])
|
||||
);
|
||||
drop(stream);
|
||||
drop(generation);
|
||||
runtime.shutdown().await;
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adapter_accepts_service_tier_resolved_by_the_terminal_response() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -466,8 +653,10 @@ async fn adapter_accepts_service_tier_resolved_by_the_terminal_response() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut config = configuration(&server.uri(), false);
|
||||
config["upstream_events"][0]["service_tier"] = json!("auto");
|
||||
config["upstream_events"][1]["service_tier"] = json!("default");
|
||||
config["upstream_events"][0]["observation"] =
|
||||
json!({"transport":"http_json","service_tier":"auto"});
|
||||
config["upstream_events"][1]["observation"] =
|
||||
json!({"transport":"http_json","service_tier":"default"});
|
||||
let (cache, _, runtime) = setup(config).await;
|
||||
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
@@ -509,6 +698,7 @@ async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() {
|
||||
let mut config = configuration(&server.uri(), false);
|
||||
config["upstream_events"] = json!([{
|
||||
"event": {"facts": []},
|
||||
"observation":{"transport":"http_json","status_code":429,"timings":{"upstream_response_ms":230}},
|
||||
"failure": {
|
||||
"kind": "rate_limited", "status": 429, "retry_after_ms": 1500,
|
||||
"message": "fixture rate limit", "code": "rate_limit_exceeded"
|
||||
@@ -520,7 +710,7 @@ async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() {
|
||||
.unwrap();
|
||||
let account = Account::new();
|
||||
let mut stream = execute(&runtime, &generation, Arc::clone(&account), "key-one", None);
|
||||
let error = stream.next().await.unwrap().unwrap_err();
|
||||
let mut error = stream.next().await.unwrap().unwrap_err();
|
||||
assert_eq!(error.kind(), ProviderErrorKind::RateLimited);
|
||||
assert_eq!(error.send_state(), UpstreamSendState::Sent);
|
||||
assert_eq!(error.upstream_status(), Some(429));
|
||||
@@ -529,6 +719,10 @@ async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() {
|
||||
"rate_limit_exceeded"
|
||||
);
|
||||
assert_eq!(error.retry_after(), Some(Duration::from_millis(1500)));
|
||||
let observations = error.take_atomic_client_events();
|
||||
let observation = observations[0].response_observation().unwrap();
|
||||
assert_eq!(observation.status_code(), Some(429));
|
||||
assert_eq!(observation.timings().upstream_response_ms, Some(230));
|
||||
let snapshot = error.stable_snapshot();
|
||||
let raw: Value = serde_json::from_str(snapshot.raw_upstream_error().unwrap().as_str()).unwrap();
|
||||
assert_eq!(raw["message"], "fixture rate limit");
|
||||
@@ -584,7 +778,8 @@ async fn adapter_overrides_auth_headers_and_rejects_stale_continuation() {
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
|
||||
let mut config = configuration(&server.uri(), false);
|
||||
config["upstream_callbacks"][0]["params"]["headers"] = json!([["Authorization", "injected"]]);
|
||||
config["upstream_callbacks"][0]["params"]["headers"] =
|
||||
json!([["Authorization", b"injected".to_vec()]]);
|
||||
let (cache, _, runtime) = setup(config).await;
|
||||
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
@@ -636,8 +831,13 @@ async fn adapter_does_not_publish_completed_when_rpc_fails_after_terminal_frame(
|
||||
.contains("fixture terminal fault")
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("fixture terminal fault"));
|
||||
let facts = error
|
||||
.take_atomic_client_events()
|
||||
let atomic = error.take_atomic_client_events();
|
||||
assert!(
|
||||
atomic
|
||||
.iter()
|
||||
.any(|event| event.response_observation().is_some())
|
||||
);
|
||||
let facts = atomic
|
||||
.into_iter()
|
||||
.flat_map(|event| event.into_parts().0)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -726,11 +926,12 @@ async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_hdr_async(
|
||||
socket,
|
||||
|request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| {
|
||||
|request: &tokio_tungstenite::tungstenite::handshake::server::Request, mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
assert_eq!(
|
||||
request.headers()["authorization"],
|
||||
"Bearer fixture-native-token"
|
||||
);
|
||||
response.headers_mut().append("x-bytes", http::HeaderValue::from_static("handshake-value"));
|
||||
Ok(response)
|
||||
},
|
||||
)
|
||||
@@ -745,7 +946,10 @@ async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle
|
||||
}
|
||||
assert!(ws.next().await.is_none_or(|result| result.is_err()));
|
||||
});
|
||||
let records = tempfile::tempdir().unwrap();
|
||||
let marker = records.path().join("handshake.jsonl");
|
||||
let mut config = configuration(&base, false);
|
||||
config["upstream_http_marker"] = json!(marker);
|
||||
config["upstream_registration"]["adapters"][0]["transport"] = json!("websocket");
|
||||
config["upstream_events"][1]["continuation"]["scope"] = json!("connection_local");
|
||||
config["upstream_callbacks"] = json!([
|
||||
@@ -789,6 +993,22 @@ async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle
|
||||
event.unwrap();
|
||||
}
|
||||
drop(second);
|
||||
let handshakes: Vec<Value> = std::fs::read_to_string(&marker)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
||||
.filter(|value| value["status"] == 101)
|
||||
.collect();
|
||||
assert_eq!(handshakes.len(), 2);
|
||||
assert_eq!(handshakes[0], handshakes[1]);
|
||||
assert_eq!(handshakes[0]["http_version"], "HTTP/1.1");
|
||||
assert!(
|
||||
handshakes[0]["headers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(["x-bytes", b"handshake-value".to_vec()]))
|
||||
);
|
||||
|
||||
let error = execute(
|
||||
&runtime,
|
||||
&generation,
|
||||
|
||||
@@ -48,7 +48,8 @@ fn host_compatibility_requires_the_trusted_middleware_contract() {
|
||||
assert!(compatibility.supports_capability("middleware", 5));
|
||||
assert!(!compatibility.supports_capability("upstream_adapter", 1));
|
||||
assert!(!compatibility.supports_capability("upstream_adapter", 2));
|
||||
assert!(compatibility.supports_capability("upstream_adapter", 3));
|
||||
assert!(!compatibility.supports_capability("upstream_adapter", 3));
|
||||
assert!(compatibility.supports_capability("upstream_adapter", 4));
|
||||
assert!(!compatibility.supports_capability("openai", 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -1683,6 +1683,7 @@ impl Peer {
|
||||
}))
|
||||
.await?;
|
||||
for (callback, (result, payload)) in callbacks.iter().zip(results) {
|
||||
self.append_observation_marker("upstream_http_marker", &result);
|
||||
if let Some(stream) = result["stream"].as_str() {
|
||||
response_body.clear();
|
||||
let read_method = if callback["method"] == "host.http.do_stream" {
|
||||
@@ -1699,6 +1700,7 @@ impl Peer {
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
self.append_observation_marker("upstream_http_marker", &result);
|
||||
response_body.extend(chunk);
|
||||
if result["eof"] == true {
|
||||
break;
|
||||
@@ -1715,7 +1717,9 @@ impl Peer {
|
||||
let mut message = UpstreamAdapterEvent::new(
|
||||
serde_json::from_value::<ExecutionEvent>(event["event"].clone()).unwrap(),
|
||||
);
|
||||
message.service_tier = event["service_tier"].as_str().map(str::to_owned);
|
||||
message.observation = event
|
||||
.get("observation")
|
||||
.map(|value| serde_json::from_value(value.clone()).unwrap());
|
||||
message.continuation = event
|
||||
.get("continuation")
|
||||
.map(|value| serde_json::from_value(value.clone()).unwrap());
|
||||
|
||||
@@ -8,8 +8,8 @@ SDK 处于实验阶段;能力是否可用取决于宿主支持、清单声明
|
||||
| --- | --- | --- |
|
||||
| SDK 包 | `0.1.0` | Rust 开发依赖,独立于网关版本 |
|
||||
| `manifestVersion` | `2` | 安装清单格式 |
|
||||
| `package.protocolVersion` | `4` | 宿主与插件的进程通信 |
|
||||
| `contributes.*.version` | `middleware` 为 `5`,`scheduler` / `upstream_adapter` 为 `3`,其余见声明 | 单项能力合同,见[扩展项声明](docs/manifest.md#扩展项简写) |
|
||||
| `package.protocolVersion` | `5` | 宿主与插件的进程通信 |
|
||||
| `contributes.*.version` | `middleware` 为 `5`,`scheduler` 为 `3`,`upstream_adapter` 为 `4`,其余见声明 | 单项能力合同,见[扩展项声明](docs/manifest.md#扩展项简写) |
|
||||
|
||||
开发顺序:编写[清单](docs/manifest.md) → 实现[能力处理器](docs/capabilities.md) → 用 [CLI](../../../apps/plugin-cli/README.md)
|
||||
打包 → [安装、配置与启用](../../../../docs/plugins.md)。完整可运行示例位于独立仓库
|
||||
@@ -92,7 +92,7 @@ sequenceDiagram
|
||||
首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。
|
||||
普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束
|
||||
|
||||
协议 v4 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
|
||||
协议 v5 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
|
||||
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
|
||||
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付
|
||||
|
||||
|
||||
@@ -221,8 +221,9 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
|
||||
同一父调用内复用 Key 时不受后续配置发布影响;父调用结束后重新绑定才读取新配置,无需重新启用插件
|
||||
|
||||
`ModelExecuteRequest.client_key_id` 可显式选择执行 Key,不与触发插件的父请求 Key 求交。省略时继承现有模型父请求身份;没有模型父请求的调用必须提供 Key。所选 Key 的启用状态、模型规则、账号组、并发和预算继续由 Core 执行
|
||||
嵌套调用与显式 Key 调用继承入口快照中的客户端 IP 和 User-Agent;没有 HTTP 入口的后台调用保持来源未知
|
||||
|
||||
模型事件使用 `call::model::ExecutionEvent` 的 `GPE2` 封套,将 canonical 事实、宿主事实和原始 wire 分段编码。`host` 包含费用及定价明细、完整上游观测、会话状态和改写来源;当前 wire 与原始 wire 都保留二进制字节。读取或回传快照不触发第二次计费、续接提交或发送状态更新。
|
||||
模型事件使用 `call::model::ExecutionEvent` 的 `GPE2` 封套,将 canonical 事实、宿主事实和原始 wire 分段编码。`host` 包含费用及定价明细、完整上游观测、会话状态和改写来源;当前 wire 与原始 wire 都保留二进制字节;`WirePayload::WebSocket` 独立保留完整文本消息和可选 JSON 视图,不把未知文本改造成 SSE。读取或回传快照不触发第二次计费、续接提交或发送状态更新。
|
||||
非流式模型结果通过 `ModelEventBatch` 的 `HME1` 有界二进制批次返回;流式句柄只在创建它的父调用内有效。
|
||||
发起回调的插件实例在子调用中跳过,Core 在单次执行的子调用图内限制递归深度、总数和并发,避免插件调用自身形成环。
|
||||
显式 Key 的身份快照随父调用保留,每个模型执行独立计时并建立调用图;父调用结束会取消尚未完成的执行,连接空闲不消耗后续执行的期限
|
||||
@@ -237,6 +238,10 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
|
||||
父调用取消或结束时宿主回收未关闭的流,SDK 不后台预读。取消一次 `read()` 等待后,再次读取会继续同一次操作,不丢弃已经返回的分块。
|
||||
账号上游使用同一正文对象,使用方式见[受管上游](upstream-adapters.md)
|
||||
|
||||
请求和响应 `headers` 使用 `(String, Vec<u8>)`,保留重复头与非 UTF-8 值;响应另有 `http_version`。
|
||||
读取至 EOF 后通过 `body.trailers()` 取得完整尾部头,读取中只包含已收到的部分。
|
||||
`collect` 只返回正文,需要尾部头时使用 `read`;底层非流式回复携带 `trailers`,流式回复在 `stream_read` 中交付尾部头
|
||||
|
||||
`host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。
|
||||
受管 HTTP(包括 `host.upstream.http.do[_stream]`)的请求正文按有效设置
|
||||
`responses_max_decompressed_body_bytes` 检查。HTTP/request 中间件使用有效请求设置;
|
||||
@@ -247,7 +252,7 @@ attempt、路由、调度、重试和上游适配器使用执行冻结预算,
|
||||
这里检查实际出站正文,内部模型请求的转义余量不增加出站预算。响应缓冲及流窗口不随请求配置扩大
|
||||
普通调用使用父调用剩余期限;已建立的受管 HTTP 流或 WebSocket 会话按每次网络操作计时,仍受父资源取消约束
|
||||
|
||||
期限耗尽返回 `timeout`,解析、连接或响应读取失败返回 `upstream`。错误消息提供分类提示,响应正文仍由插件读取;协议故障不应直接展示内部消息
|
||||
期限耗尽返回 `timeout`,解析、连接或响应读取失败返回 `upstream`。错误 `details.kind` 和 `details.reason` 保留 Host 的故障分类与原因标记,响应正文仍由插件读取;协议故障不应直接展示内部消息
|
||||
|
||||
## 请求扩展
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
清单与宿主兼容声明的格式版本为 `2`,进程协议版本为 `4`;清单不接受 `permissions` 字段
|
||||
|
||||
RPC v4 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
|
||||
RPC v5 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
|
||||
|
||||
安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本;
|
||||
数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。
|
||||
@@ -49,7 +49,7 @@ RPC v4 的帧布局、编码与会话生命周期见[通信边界](../README.md#
|
||||
|
||||
## 扩展项简写
|
||||
|
||||
作者声明可以省略 `id` 和固定阶段。版本字段省略时为 `1`,适用于 `model_catalog`、`command_line` 和 `maintenance`;其余能力显式声明当前版本:`middleware` 为 `5`,`scheduler` 和 `upstream_adapter` 为 `3`,认证、路由、重试、观察和管理能力为 `2`:
|
||||
作者声明可以省略 `id` 和固定阶段。版本字段省略时为 `1`,适用于 `model_catalog`、`command_line` 和 `maintenance`;其余能力显式声明当前版本:`middleware` 为 `5`,`scheduler` 为 `3`,`upstream_adapter` 为 `4`,认证、路由、重试、观察和管理能力为 `2`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 受管上游适配器
|
||||
|
||||
`upstream_adapter` v3 是现有生成请求洋葱链的终端,只挂到内置 `openai` / `xai`,不替换独立图像或压缩端点。
|
||||
`upstream_adapter` v4 是现有生成请求洋葱链的终端,只挂到内置 `openai` / `xai`,不替换独立图像或压缩端点。
|
||||
插件定义 Base URL、业务路径、请求编码和响应解析;宿主继续持有身份、ACL、选号、租约、凭据、代理、重试和账本。
|
||||
普通请求或响应加工使用 [middleware](capabilities.md#洋葱中间件)
|
||||
|
||||
## 声明与绑定
|
||||
|
||||
清单声明 `upstream_adapter`、版本 `3`,以及匹配的 `inputFormats` / `outputFormats`;固定阶段 `upstream` 由作者清单规范化入口生成。
|
||||
清单声明 `upstream_adapter`、版本 `4`,以及匹配的 `inputFormats` / `outputFormats`;固定阶段 `upstream` 由作者清单规范化入口生成。
|
||||
使用 `PluginBuilder::on` 注册
|
||||
`methods::UPSTREAM_ADAPTER_REGISTER` 与 `methods::UPSTREAM_ADAPTER_EXECUTE`
|
||||
|
||||
@@ -55,15 +55,15 @@ while let Some(bytes) = body.read().await? {
|
||||
|
||||
正文与普通 `HostClient::http` 共用 `read`、`collect(maximum_bytes)` 和 `close`;每次读取至多 64 KiB,
|
||||
不后台预读,不向作者暴露流 ID。提前关闭及收集超限会释放正文,父调用结束或取消时宿主兜底回收。
|
||||
JSON 可有界收集,SSE 应增量解析,不能把读取块当成完整事件
|
||||
JSON 可有界收集,SSE 应增量解析,不能把读取块当成完整事件。HTTP 版本、字节头及 trailers 的读取见[网络](capabilities.md#网络)
|
||||
|
||||
WebSocket 使用 `call.host.upstream_websocket(request).await?`,返回
|
||||
`UpstreamWebSocketUpgrade::Connected { headers, connection }` 或 `Rejected { status, headers, body }`。
|
||||
`UpstreamWebSocketUpgrade::Connected { http_version, headers, connection }` 或 `Rejected { status, http_version, headers, body, trailers }`。
|
||||
连接提供 `send(kind, body)`、`read()`、`close()`,支持完整文本与二进制消息,Ping/Pong 由宿主处理。
|
||||
读写可同时进行,等待上游输出时可以发送控制消息;有先后依赖的消息须顺序等待 `send`。
|
||||
同一时刻只允许一个读取者;取消一次 `read()` 等待后,再次读取继续同一次接收,不跳过消息。
|
||||
`close` 会唤醒在途操作并释放连接,并发关闭等待同一次完成;读取超时或连接错误也会关闭连接。
|
||||
握手拒绝保留 HTTP 状态和正文。底层 `host.upstream.*` 是 SDK 使用的资源协议
|
||||
握手拒绝保留 HTTP 状态、正文和 trailers;连接复用返回该连接最初的握手版本和响应头。底层 `host.upstream.*` 是 SDK 使用的资源协议
|
||||
|
||||
```rust,ignore
|
||||
let (message, ()) = tokio::try_join!(
|
||||
@@ -80,16 +80,28 @@ let (message, ()) = tokio::try_join!(
|
||||
|
||||
执行返回 `TypedReply<Empty>` 的有界 `ResponseStream`。每个 chunk 使用 `UpstreamAdapterEvent::encode()`,
|
||||
包含标准事实与可选原始 wire;事件需符合开始、内容、工具、用量和唯一结束的顺序。
|
||||
原始 JSON / SSE 字节与标准事实分开传递,错误使用 `UpstreamFailure`;宿主网络观测决定发送状态,插件不能将其降为未发送
|
||||
原始 JSON / SSE / WebSocket 字节与标准事实分开传递,错误使用 `UpstreamFailure`;宿主网络观测决定发送状态,插件不能将其降为未发送
|
||||
|
||||
初始 RPC 回复受插件处理预算约束,默认上限为 120 秒;持续接收与解析应放在返回的流中。
|
||||
初始流式回复后,流的寿命遵循模型请求期限与取消,受管 HTTP 正文每次读取单独计时。
|
||||
持续有数据的流不会因初始 RPC 预算耗尽而结束,单次读取超时、客户端取消和流结束仍会回收资源
|
||||
|
||||
用量提交标准 `Usage`,费用由内置 Provider 按已冻结的上游模型与价格计算,响应回显模型不切换计价。
|
||||
`service_tier` 记录上游回显的最新档位,允许上游在完成时确定实际值;OpenAI 计价沿用请求策略,回显档位仅作观测。
|
||||
`observation.service_tier` 记录上游回显的最新档位,允许上游在完成时确定实际值;OpenAI 计价沿用请求策略,回显档位仅作观测。
|
||||
未知用量或价格保留未知。宿主确认 RPC 正常结束后才发布完成事实,插件不能独立结算或组织换号重试
|
||||
|
||||
`UpstreamAdapterEvent.observation` 接收 `ResponseObservation` 原始观测:HTTP 版本、状态、请求 ID、回显模型、
|
||||
服务档位、字节响应头、Provider JSON 元数据及 `ResponseTimings`。`transport` 与注册传输类型一致;
|
||||
已提供字段覆盖同次执行的旧值,缺失字段或空 headers 保留已有观测,允许独立的观测事件及失败事件携带观测。
|
||||
`ExecutionEvent.host` 是宿主结果的只读快照,不能用于回写费用、发送状态或续接
|
||||
|
||||
`first_*_ms` 相对 Core 请求计时起点,包含本次请求的排队和重试等待。
|
||||
`request_elapsed_ms` 提供宿主发送本次适配器调用前已耗的时间,插件自行计时须对齐该基线;
|
||||
没有报告时 Runtime 在收到首事件、非空正文/推理/工具增量时记录相应时间。
|
||||
开始、心跳和空增量不构成首个输出,显式报告的计时优先于宿主缺省观测。
|
||||
`upstream_response_ms`、`upstream_api_overhead_ms`、`upstream_engine_ms` 只能来自上游原始计时,
|
||||
没有来源时保持缺失。观测合同不包含总耗时、速率或计算费用,这些由宿主业务层消费原始值计算
|
||||
|
||||
成功终态可附带 `UpstreamContinuation`,包含上游响应 ID、不超过 32 KiB 的私有续接材料和作用域:
|
||||
|
||||
- `persisted`:续接材料随宿主会话保存,仍受实例、代次、Key、账号和凭据版本限制
|
||||
|
||||
@@ -63,15 +63,17 @@ pub struct HttpRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub http_version: String,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
pub stream: Option<String>,
|
||||
pub trailers: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// 宿主账号列表查询;cursor 是上次结果返回的稳定账号 ID
|
||||
|
||||
@@ -142,6 +142,7 @@ fn attach(target: Option<&mut Vec<u8>>, bytes: &[u8]) -> Result<(), ExecutionEnc
|
||||
fn raw_wire(wire: Option<&mut super::WireEvent>) -> Option<&mut Vec<u8>> {
|
||||
match &mut wire?.payload {
|
||||
WirePayload::Json { raw_sse, .. } => raw_sse.as_mut(),
|
||||
WirePayload::WebSocket { message, .. } => Some(message),
|
||||
WirePayload::RawSse { frame } => Some(frame),
|
||||
WirePayload::RawJson { body } | WirePayload::RawBody { body } => Some(body),
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::WireEvent;
|
||||
use crate::call::{
|
||||
middleware::MiddlewareHeader,
|
||||
observation::{RequestMoney, RequestTimings},
|
||||
};
|
||||
use crate::call::{middleware::MiddlewareHeader, observation::RequestMoney};
|
||||
|
||||
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -76,12 +73,60 @@ pub struct ResponseObservation {
|
||||
pub request_id: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub timings: RequestTimings,
|
||||
#[serde(default)]
|
||||
pub timings: ResponseTimings,
|
||||
#[serde(default)]
|
||||
pub client_headers: Vec<MiddlewareHeader>,
|
||||
/// Provider 已产生的完整观测 JSON 文本,保留原始表示
|
||||
pub provider_metadata: Option<String>,
|
||||
}
|
||||
|
||||
/// 原始上游计时;不包含最终总耗时、速率或费用等业务结果
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResponseTimings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub transport_decision_wait_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub connect_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub headers_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_event_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_reasoning_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_text_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_token_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_processing_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
}
|
||||
|
||||
impl ResponseObservation {
|
||||
#[must_use]
|
||||
pub fn new(transport: impl Into<String>) -> Self {
|
||||
Self {
|
||||
transport: transport.into(),
|
||||
http_version: None,
|
||||
websocket_pool: None,
|
||||
status_code: None,
|
||||
request_id: None,
|
||||
service_tier: None,
|
||||
upstream_response_model: None,
|
||||
timings: ResponseTimings::default(),
|
||||
client_headers: Vec::new(),
|
||||
provider_metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionState {
|
||||
|
||||
@@ -158,6 +158,12 @@ pub enum WirePayload {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
raw_sse: Option<Vec<u8>>,
|
||||
},
|
||||
/// 完整 WebSocket 文本消息及可用的解析视图,原文独立放入二进制段
|
||||
WebSocket {
|
||||
event: Option<String>,
|
||||
data: Option<serde_json::Value>,
|
||||
message: Vec<u8>,
|
||||
},
|
||||
/// 注释、非 JSON data 等原生 SSE 表达,不能夹带第二个帧
|
||||
RawSse { frame: Vec<u8> },
|
||||
/// 一个完整的非流式 JSON 响应正文,保留空白与字段顺序
|
||||
|
||||
@@ -10,14 +10,14 @@ use crate::call::model::{
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Metadata {
|
||||
service_tier: Option<String>,
|
||||
observation: Option<super::super::model::facts::ResponseObservation>,
|
||||
continuation: Option<UpstreamContinuation>,
|
||||
failure: Option<UpstreamFailure>,
|
||||
}
|
||||
|
||||
pub(super) fn encode(value: UpstreamAdapterEvent) -> Result<Vec<u8>, ExecutionEncodingError> {
|
||||
let metadata = Metadata {
|
||||
service_tier: value.service_tier,
|
||||
observation: value.observation,
|
||||
continuation: value.continuation,
|
||||
failure: value.failure,
|
||||
};
|
||||
@@ -28,7 +28,7 @@ pub(super) fn decode(bytes: &[u8]) -> Result<UpstreamAdapterEvent, ExecutionEnco
|
||||
let (metadata, [event]): (Metadata, _) = unpack(*b"GPA1", bytes)?;
|
||||
Ok(UpstreamAdapterEvent {
|
||||
event: ExecutionEvent::decode(event)?,
|
||||
service_tier: metadata.service_tier,
|
||||
observation: metadata.observation,
|
||||
continuation: metadata.continuation,
|
||||
failure: metadata.failure,
|
||||
})
|
||||
|
||||
@@ -95,6 +95,8 @@ pub struct UpstreamAdapterRequest {
|
||||
pub client_transport: String,
|
||||
/// 宿主冻结的 Fast 策略:default、enabled 或 disabled
|
||||
pub fast_mode: String,
|
||||
/// 宿主发送调用前相对 Core 请求计时起点已经过的毫秒数
|
||||
pub request_elapsed_ms: u64,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
/// 只能来自同账号、同插件代次的宿主续接记录,不从客户端正文信任此值
|
||||
pub continuation: Option<UpstreamContinuation>,
|
||||
@@ -129,8 +131,8 @@ pub struct UpstreamContinuation {
|
||||
/// 一个完整 GPE2 事件与可选的结算/续接事实;见 [`Self::encode`]
|
||||
pub struct UpstreamAdapterEvent {
|
||||
pub event: ExecutionEvent,
|
||||
/// 上游回显的最新服务档位,可在终态确定;是否参与计价由内置 Provider 决定
|
||||
pub service_tier: Option<String>,
|
||||
/// 本次执行的上游观测;不使用 ExecutionEvent.host 回写宿主账本
|
||||
pub observation: Option<super::model::facts::ResponseObservation>,
|
||||
pub continuation: Option<UpstreamContinuation>,
|
||||
pub failure: Option<UpstreamFailure>,
|
||||
}
|
||||
@@ -140,7 +142,7 @@ impl UpstreamAdapterEvent {
|
||||
pub const fn new(event: ExecutionEvent) -> Self {
|
||||
Self {
|
||||
event,
|
||||
service_tier: None,
|
||||
observation: None,
|
||||
continuation: None,
|
||||
failure: None,
|
||||
}
|
||||
@@ -186,7 +188,7 @@ pub struct UpstreamFailure {
|
||||
pub struct UpstreamHttpRequest {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
#[serde(default)]
|
||||
pub query: Vec<(String, String)>,
|
||||
}
|
||||
@@ -195,7 +197,7 @@ pub struct UpstreamHttpRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpstreamWebSocketRequest {
|
||||
pub path: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
#[serde(default)]
|
||||
pub query: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ impl Capability {
|
||||
| Self::RetryPolicy
|
||||
| Self::Observer
|
||||
| Self::Management => &[2],
|
||||
Self::Scheduler | Self::UpstreamAdapter => &[3],
|
||||
Self::Scheduler => &[3],
|
||||
Self::UpstreamAdapter => &[4],
|
||||
Self::ModelCatalog | Self::CommandLine | Self::Maintenance => &[1],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ use crate::{
|
||||
/// 响应头与惰性正文;不包含宿主的内部流句柄
|
||||
pub struct HostHttpResponse {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub http_version: String,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
pub body: HostHttpBody,
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ pub struct HostHttpBody {
|
||||
host: HostClient,
|
||||
stream: Option<String>,
|
||||
upstream: bool,
|
||||
trailers: Vec<(String, Vec<u8>)>,
|
||||
read: PendingRead<Result<HostReply, SessionError>>,
|
||||
}
|
||||
|
||||
@@ -64,11 +66,13 @@ impl HostClient {
|
||||
}
|
||||
Ok(HostHttpResponse {
|
||||
status: response.status,
|
||||
http_version: response.http_version,
|
||||
headers: response.headers,
|
||||
body: HostHttpBody {
|
||||
host: self.clone(),
|
||||
stream: response.stream,
|
||||
upstream,
|
||||
trailers: response.trailers,
|
||||
read: PendingRead::default(),
|
||||
},
|
||||
})
|
||||
@@ -76,6 +80,12 @@ impl HostClient {
|
||||
}
|
||||
|
||||
impl HostHttpBody {
|
||||
/// 已收到的响应尾部头;读取至 EOF 后完整,名称、多值和字节原样保留
|
||||
#[must_use]
|
||||
pub fn trailers(&self) -> &[(String, Vec<u8>)] {
|
||||
&self.trailers
|
||||
}
|
||||
|
||||
/// 按需读取至多 64 KiB,EOF 后再次读取不再调用宿主
|
||||
/// 取消一次等待后,再次调用会继续同一次读取,不丢弃分块
|
||||
///
|
||||
@@ -89,6 +99,8 @@ impl HostHttpBody {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Read {
|
||||
eof: bool,
|
||||
#[serde(default)]
|
||||
trailers: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
let maximum_bytes = u32::try_from(self.host.maximum_stream_chunk_bytes().min(64 * 1024))
|
||||
.map_err(|_| invalid())?;
|
||||
@@ -112,6 +124,7 @@ impl HostHttpBody {
|
||||
let _ = self.close().await;
|
||||
return Err(invalid());
|
||||
}
|
||||
self.trailers.extend(result.trailers);
|
||||
if result.eof {
|
||||
self.stream = None;
|
||||
Ok(None)
|
||||
|
||||
@@ -21,13 +21,16 @@ use crate::{
|
||||
/// 握手拒绝仍保留 HTTP 状态及正文,供适配器解析上游错误
|
||||
pub enum UpstreamWebSocketUpgrade {
|
||||
Connected {
|
||||
headers: Vec<(String, String)>,
|
||||
http_version: String,
|
||||
headers: Vec<(String, Vec<u8>)>,
|
||||
connection: UpstreamWebSocket,
|
||||
},
|
||||
Rejected {
|
||||
status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
http_version: String,
|
||||
headers: Vec<(String, Vec<u8>)>,
|
||||
body: Vec<u8>,
|
||||
trailers: Vec<(String, Vec<u8>)>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -57,6 +60,7 @@ impl HostClient {
|
||||
}
|
||||
Ok(if response.status == 101 {
|
||||
UpstreamWebSocketUpgrade::Connected {
|
||||
http_version: response.http_version,
|
||||
headers: response.headers,
|
||||
connection: UpstreamWebSocket {
|
||||
host: self.clone(),
|
||||
@@ -67,8 +71,10 @@ impl HostClient {
|
||||
} else {
|
||||
UpstreamWebSocketUpgrade::Rejected {
|
||||
status: response.status,
|
||||
http_version: response.http_version,
|
||||
headers: response.headers,
|
||||
body,
|
||||
trailers: response.trailers,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{CallContext, Handshake, PluginFault};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 4;
|
||||
pub const PROTOCOL_VERSION: u32 = 5;
|
||||
|
||||
/// 元数据与二进制载荷分开,流分块不经过 JSON/base64
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -11,7 +11,8 @@ fn request() -> UpstreamAdapterRequest {
|
||||
"adapter_id":"example", "provider":"openai", "upstream_model":"fixture",
|
||||
"client_key_id":"key", "account_id":"account", "credential_revision":1,
|
||||
"protocol":"openai", "client_transport":"http_json", "fast_mode":"default",
|
||||
"headers":[["x-fixture",[97,98]]], "continuation":null
|
||||
"headers":[["x-fixture",[97,98]]], "continuation":null,
|
||||
"request_elapsed_ms":0
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -108,10 +108,23 @@ impl PluginHandler for NetworkHandler {
|
||||
if call.params["collect"] == true {
|
||||
return Ok(CallReply::unary(json!({}), response.body.collect(3).await?));
|
||||
}
|
||||
if call.params["metadata"] == true {
|
||||
assert_eq!(response.http_version, "HTTP/1.1");
|
||||
assert_eq!(
|
||||
response.headers,
|
||||
vec![
|
||||
("x-bytes".to_owned(), vec![0xff]),
|
||||
("x-bytes".to_owned(), b"other".to_vec())
|
||||
]
|
||||
);
|
||||
}
|
||||
let mut body = response.body;
|
||||
let bytes = body.read().await?.unwrap();
|
||||
assert!(body.read().await?.is_none());
|
||||
assert!(body.read().await?.is_none());
|
||||
if call.params["metadata"] == true {
|
||||
assert_eq!(body.trailers(), &[("x-tail".to_owned(), vec![0xfe])]);
|
||||
}
|
||||
body.close().await?;
|
||||
body.close().await?;
|
||||
Ok(CallReply::unary(json!({}), bytes))
|
||||
@@ -146,7 +159,14 @@ async fn reply_callback(
|
||||
async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
|
||||
for method in ["http", "upstream"] {
|
||||
let (mut host, task) = start_session(NetworkHandler).await;
|
||||
send_call(&mut host, 1, method, json!({}), b"request".to_vec()).await;
|
||||
send_call(
|
||||
&mut host,
|
||||
1,
|
||||
method,
|
||||
json!({"metadata":true}),
|
||||
b"request".to_vec(),
|
||||
)
|
||||
.await;
|
||||
let prefix = if method == "http" {
|
||||
"host.http"
|
||||
} else {
|
||||
@@ -155,7 +175,7 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
|
||||
let frame = reply_callback(
|
||||
&mut host,
|
||||
&format!("{prefix}.do_stream"),
|
||||
json!({"status":200,"headers":[],"stream":"private-handle"}),
|
||||
json!({"status":200,"http_version":"HTTP/1.1","trailers":[],"headers":[["x-bytes",[255]],["x-bytes",b"other".to_vec()]],"stream":"private-handle"}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
@@ -173,7 +193,7 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
|
||||
reply_callback(
|
||||
&mut host,
|
||||
&format!("{prefix}.stream_read"),
|
||||
json!({"eof":true}),
|
||||
json!({"eof":true,"trailers":[["x-tail",[254]]]}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
@@ -191,7 +211,7 @@ async fn bounded_collection_closes_the_host_stream_before_reporting_overflow() {
|
||||
reply_callback(
|
||||
&mut host,
|
||||
"host.upstream.http.do_stream",
|
||||
json!({"status":200,"headers":[],"stream":"private-handle"}),
|
||||
json!({"status":200,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":"private-handle"}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
@@ -222,7 +242,7 @@ async fn websocket_session_handles_messages_eof_and_http_rejection() {
|
||||
reply_callback(
|
||||
&mut host,
|
||||
"host.upstream.websocket.open",
|
||||
json!({"status":101,"headers":[],"stream":null}),
|
||||
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
@@ -249,7 +269,7 @@ async fn websocket_session_handles_messages_eof_and_http_rejection() {
|
||||
reply_callback(
|
||||
&mut host,
|
||||
"host.upstream.websocket.open",
|
||||
json!({"status":401,"headers":[],"stream":null}),
|
||||
json!({"status":401,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
|
||||
b"unauthorized".to_vec(),
|
||||
)
|
||||
.await;
|
||||
@@ -268,7 +288,7 @@ async fn websocket_send_and_read_callbacks_can_be_in_flight_together() {
|
||||
reply_callback(
|
||||
&mut host,
|
||||
"host.upstream.websocket.open",
|
||||
json!({"status":101,"headers":[],"stream":null}),
|
||||
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
@@ -329,7 +349,7 @@ async fn websocket_close_can_be_retried_after_cancelling_its_future() {
|
||||
reply_callback(
|
||||
&mut host,
|
||||
"host.upstream.websocket.open",
|
||||
json!({"status":101,"headers":[],"stream":null}),
|
||||
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
@@ -362,7 +382,7 @@ async fn websocket_close_does_not_wait_for_a_pending_reader() {
|
||||
reply_callback(
|
||||
&mut host,
|
||||
"host.upstream.websocket.open",
|
||||
json!({"status":101,"headers":[],"stream":null}),
|
||||
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -105,7 +105,7 @@ async fn verify_cancelled_read(method: &str) {
|
||||
reply(
|
||||
&mut host,
|
||||
id,
|
||||
json!({"status":status,"headers":[],"stream":stream}),
|
||||
json!({"status":status,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":stream}),
|
||||
b"",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -135,7 +135,7 @@ fn capability_contract_versions_are_checked_independently_of_rpc_version() {
|
||||
for (capability, stage, supported, needs_formats) in [
|
||||
(Capability::Middleware, Stage::Request, 5, true),
|
||||
(Capability::Management, Stage::Management, 2, false),
|
||||
(Capability::UpstreamAdapter, Stage::Upstream, 3, true),
|
||||
(Capability::UpstreamAdapter, Stage::Upstream, 4, true),
|
||||
(Capability::Scheduler, Stage::Scheduling, 3, false),
|
||||
] {
|
||||
for mut manifest in [source_manifest(), packaged_manifest()] {
|
||||
@@ -396,7 +396,7 @@ fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() {
|
||||
|
||||
let handshake = format!(
|
||||
r#"{{
|
||||
"protocol_version":4,"artifact_sha256":"digest","plugin_id":"test.example",
|
||||
"protocol_version":5,"artifact_sha256":"digest","plugin_id":"test.example",
|
||||
"instance_id":"instance","generation":1,"incarnation":"incarnation",
|
||||
"contributes":{{"middleware":{declaration},"middleware":{declaration}}}
|
||||
}}"#
|
||||
|
||||
+1
-1
Submodule modules/plugins updated: 7500732163...28199b8114
Reference in New Issue
Block a user