From 534a7d75d4e683924d0453fc44be82076d938f69 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Tue, 7 Apr 2026 00:29:17 +0800 Subject: [PATCH 1/2] feat(provider): add openai chat format for new provider --- docs/internals/llm-types.md | 2 + src/gateway/formats/mod.rs | 3 + src/gateway/formats/openai/mod.rs | 233 ++++++++++++++++++++++++++++++ src/gateway/mod.rs | 1 + 4 files changed, 239 insertions(+) create mode 100644 src/gateway/formats/mod.rs create mode 100644 src/gateway/formats/openai/mod.rs diff --git a/docs/internals/llm-types.md b/docs/internals/llm-types.md index bb997fe..fc6411e 100644 --- a/docs/internals/llm-types.md +++ b/docs/internals/llm-types.md @@ -111,6 +111,8 @@ The hub format remains OpenAI Chat. Every format therefore explains how to: The trait also includes a native escape hatch for providers that can serve the source format directly. +`OpenAIChatFormat` is the identity implementation of that contract: hub requests, responses, and streamed chunks pass through unchanged, and the format exposes no native bypass because OpenAI Chat is already the hub representation. + ### Explicit stream state Two stream-state associated types are explicit: diff --git a/src/gateway/formats/mod.rs b/src/gateway/formats/mod.rs new file mode 100644 index 0000000..954c170 --- /dev/null +++ b/src/gateway/formats/mod.rs @@ -0,0 +1,3 @@ +pub mod openai; + +pub use openai::OpenAIChatFormat; diff --git a/src/gateway/formats/openai/mod.rs b/src/gateway/formats/openai/mod.rs new file mode 100644 index 0000000..8272033 --- /dev/null +++ b/src/gateway/formats/openai/mod.rs @@ -0,0 +1,233 @@ +use crate::gateway::{ + error::{GatewayError, Result}, + traits::{ChatFormat, NativeHandler, ProviderCapabilities}, + types::{ + common::BridgeContext, + openai::{ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse}, + }, +}; + +pub struct OpenAIChatFormat; + +impl ChatFormat for OpenAIChatFormat { + type Request = ChatCompletionRequest; + type Response = ChatCompletionResponse; + type StreamChunk = ChatCompletionChunk; + type BridgeState = (); + type NativeStreamState = (); + + fn name() -> &'static str { + "openai_chat" + } + + fn is_stream(req: &Self::Request) -> bool { + req.stream.unwrap_or(false) + } + + fn extract_model(req: &Self::Request) -> &str { + &req.model + } + + fn to_hub(req: &Self::Request) -> Result<(ChatCompletionRequest, BridgeContext)> { + Ok((req.clone(), BridgeContext::default())) + } + + fn from_hub(resp: &Self::Response, _ctx: &BridgeContext) -> Result { + Ok(resp.clone()) + } + + fn from_hub_stream( + chunk: &ChatCompletionChunk, + _state: &mut Self::BridgeState, + _ctx: &BridgeContext, + ) -> Result> { + Ok(vec![chunk.clone()]) + } + + fn native_support(_provider: &dyn ProviderCapabilities) -> Option> + where + Self: Sized, + { + None + } + + fn transform_native_stream_chunk( + _provider: &dyn ProviderCapabilities, + _raw: &str, + _state: &mut Self::NativeStreamState, + ) -> Result> { + Err(GatewayError::Bridge( + "OpenAIChatFormat has no native stream path".into(), + )) + } + + fn serialize_chunk_payload(chunk: &Self::StreamChunk) -> String { + serde_json::to_string(chunk).expect("chat completion chunk should serialize") + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::OpenAIChatFormat; + use crate::gateway::{ + error::GatewayError, + provider_instance::ProviderAuth, + traits::{ChatFormat, ProviderCapabilities, ProviderMeta, StreamReaderKind}, + types::{common::BridgeContext, openai::*}, + }; + + struct DummyProvider; + + impl ProviderMeta for DummyProvider { + fn name(&self) -> &'static str { + "dummy" + } + + fn default_base_url(&self) -> &'static str { + "https://example.com" + } + + fn stream_reader_kind(&self) -> StreamReaderKind { + StreamReaderKind::Sse + } + + fn build_auth_headers( + &self, + _auth: &ProviderAuth, + ) -> crate::gateway::error::Result { + Ok(http::HeaderMap::new()) + } + } + + impl crate::gateway::traits::ChatTransform for DummyProvider {} + + impl ProviderCapabilities for DummyProvider {} + + #[test] + fn request_round_trips_through_hub_identity() { + let request: ChatCompletionRequest = serde_json::from_value(json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "stream": true, + "custom_provider_field": "value" + })) + .unwrap(); + + let (hub, ctx) = OpenAIChatFormat::to_hub(&request).unwrap(); + + assert_eq!( + serde_json::to_value(&hub).unwrap(), + serde_json::to_value(&request).unwrap() + ); + assert!(ctx.anthropic_messages_extras.is_none()); + assert!(ctx.openai_responses_extras.is_none()); + assert!(ctx.passthrough.is_empty()); + } + + #[test] + fn response_round_trips_from_hub_identity() { + let response: ChatCompletionResponse = serde_json::from_value(json!({ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Hello!" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } + })) + .unwrap(); + + let bridged = OpenAIChatFormat::from_hub(&response, &BridgeContext::default()).unwrap(); + + assert_eq!( + serde_json::to_value(&bridged).unwrap(), + serde_json::to_value(&response).unwrap() + ); + } + + #[test] + fn stream_chunk_round_trips_and_serializes_payload() { + let chunk: ChatCompletionChunk = serde_json::from_value(json!({ + "id": "chatcmpl-123", + "object": "chat.completion.chunk", + "created": 1677652288, + "model": "gpt-4", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call_abc", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"loc" + } + }] + } + }] + })) + .unwrap(); + + let emitted = + OpenAIChatFormat::from_hub_stream(&chunk, &mut (), &BridgeContext::default()).unwrap(); + + assert_eq!(emitted.len(), 1); + assert_eq!( + serde_json::to_value(&emitted[0]).unwrap(), + serde_json::to_value(&chunk).unwrap() + ); + assert_eq!( + OpenAIChatFormat::serialize_chunk_payload(&emitted[0]), + serde_json::to_string(&chunk).unwrap() + ); + assert!(OpenAIChatFormat::stream_end_events(&mut (), &BridgeContext::default()).is_empty()); + } + + #[test] + fn is_stream_and_extract_model_use_request_fields() { + let streaming_request: ChatCompletionRequest = serde_json::from_value(json!({ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello"}], + "stream": true + })) + .unwrap(); + let non_streaming_request: ChatCompletionRequest = serde_json::from_value(json!({ + "model": "gpt-4.1", + "messages": [{"role": "user", "content": "Hello"}] + })) + .unwrap(); + + assert!(OpenAIChatFormat::is_stream(&streaming_request)); + assert!(!OpenAIChatFormat::is_stream(&non_streaming_request)); + assert_eq!( + OpenAIChatFormat::extract_model(&streaming_request), + "gpt-4o-mini" + ); + } + + #[test] + fn native_stream_path_returns_bridge_error() { + let provider = DummyProvider; + let error = OpenAIChatFormat::transform_native_stream_chunk(&provider, "data: {}", &mut ()) + .unwrap_err(); + + assert!(matches!( + error, + GatewayError::Bridge(message) if message.contains("no native stream path") + )); + assert!(OpenAIChatFormat::native_support(&provider).is_none()); + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 0d6d8de..d64e1fb 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod formats; pub mod provider_instance; pub mod traits; pub mod types; From f2df725c8215c15be48b500592a02e7946d91b59 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Tue, 7 Apr 2026 00:45:36 +0800 Subject: [PATCH 2/2] fix comments --- src/gateway/formats/openai/mod.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/gateway/formats/openai/mod.rs b/src/gateway/formats/openai/mod.rs index 8272033..bde0581 100644 --- a/src/gateway/formats/openai/mod.rs +++ b/src/gateway/formats/openai/mod.rs @@ -52,13 +52,13 @@ impl ChatFormat for OpenAIChatFormat { } fn transform_native_stream_chunk( - _provider: &dyn ProviderCapabilities, + provider: &dyn ProviderCapabilities, _raw: &str, _state: &mut Self::NativeStreamState, ) -> Result> { - Err(GatewayError::Bridge( - "OpenAIChatFormat has no native stream path".into(), - )) + Err(GatewayError::NativeNotSupported { + provider: provider.name().into(), + }) } fn serialize_chunk_payload(chunk: &Self::StreamChunk) -> String { @@ -219,14 +219,14 @@ mod tests { } #[test] - fn native_stream_path_returns_bridge_error() { + fn native_stream_path_returns_native_not_supported_error() { let provider = DummyProvider; let error = OpenAIChatFormat::transform_native_stream_chunk(&provider, "data: {}", &mut ()) .unwrap_err(); assert!(matches!( error, - GatewayError::Bridge(message) if message.contains("no native stream path") + GatewayError::NativeNotSupported { provider } if provider == "dummy" )); assert!(OpenAIChatFormat::native_support(&provider).is_none()); }