Skip to content

Commit 8567670

Browse files
authored
feat(provider): add anthropic chat format for new provider (#20)
1 parent a95ba1a commit 8567670

6 files changed

Lines changed: 1111 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
name = "aisix"
33
version = "0.1.0"
44
edition = "2024"
5+
rust-version = "1.85"
56

67
[dependencies]
78
log = { version = "0.4.29", features = ["kv", "kv_serde"] }

docs/internals/llm-types.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ That keeps stream state typed and local to the format implementation instead of
124124

125125
Hub stream state also keeps partially assembled tool calls keyed by `(choice_index, tool_call_index)`, because tool call indices are scoped to a streamed choice rather than globally unique across the whole chunk stream.
126126

127+
The same stream state also carries response metadata such as streamed `id`, `model`, and `created` values so provider-specific SSE adapters can emit well-formed hub chunks even when later provider events omit that metadata.
128+
127129
### Provider layering
128130

129131
The provider side is split into three layers.
@@ -138,6 +140,8 @@ For OpenAI-compatible providers, the concrete definition can stay very small. Th
138140

139141
`OpenAIDef` remains hand-written because it needs its own default quirk profile, while `DeepSeek` is the first macro-generated provider in the new stack.
140142

143+
`AnthropicDef` is the first hand-written non-OpenAI-compatible provider in the new stack. It combines a custom `ChatTransform` with `NativeAnthropicMessagesSupport` so Anthropic Messages requests can bypass the hub format when the caller already speaks the native protocol.
144+
141145
### Runtime provider instances
142146

143147
`ProviderInstance` binds a shared provider definition to runtime auth, base URL overrides, and custom headers.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
pub mod transform;
2+
3+
use std::borrow::Cow;
4+
5+
use http::{HeaderMap, HeaderValue};
6+
use serde_json::Value;
7+
8+
use self::transform::{
9+
anthropic_to_openai_response, openai_to_anthropic_request, parse_anthropic_native_sse,
10+
parse_anthropic_sse_to_openai,
11+
};
12+
use crate::gateway::{
13+
error::{GatewayError, Result},
14+
provider_instance::ProviderAuth,
15+
traits::{
16+
AnthropicMessagesNativeStreamState, ChatStreamState, ChatTransform,
17+
NativeAnthropicMessagesSupport, ProviderCapabilities, ProviderMeta,
18+
},
19+
types::{
20+
anthropic::{AnthropicMessagesRequest, AnthropicMessagesResponse, AnthropicStreamEvent},
21+
openai::{ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse},
22+
},
23+
};
24+
25+
pub struct AnthropicDef;
26+
27+
impl ProviderMeta for AnthropicDef {
28+
fn name(&self) -> &'static str {
29+
"anthropic"
30+
}
31+
32+
fn default_base_url(&self) -> &'static str {
33+
"https://api.anthropic.com"
34+
}
35+
36+
fn chat_endpoint_path(&self, _model: &str) -> Cow<'static, str> {
37+
Cow::Borrowed("/v1/messages")
38+
}
39+
40+
fn build_auth_headers(&self, auth: &ProviderAuth) -> Result<HeaderMap> {
41+
let mut headers = HeaderMap::new();
42+
headers.insert(
43+
http::header::HeaderName::from_static("x-api-key"),
44+
HeaderValue::from_str(auth.api_key_for(self.name())?)
45+
.map_err(|error| GatewayError::Validation(error.to_string()))?,
46+
);
47+
headers.insert(
48+
http::header::HeaderName::from_static("anthropic-version"),
49+
HeaderValue::from_static("2023-06-01"),
50+
);
51+
Ok(headers)
52+
}
53+
}
54+
55+
impl ChatTransform for AnthropicDef {
56+
fn transform_request(&self, request: &ChatCompletionRequest) -> Result<Value> {
57+
serde_json::to_value(openai_to_anthropic_request(request)?)
58+
.map_err(|error| GatewayError::Transform(error.to_string()))
59+
}
60+
61+
fn transform_response(&self, body: Value) -> Result<ChatCompletionResponse> {
62+
let response: AnthropicMessagesResponse = serde_json::from_value(body)
63+
.map_err(|error| GatewayError::Transform(error.to_string()))?;
64+
anthropic_to_openai_response(&response)
65+
}
66+
67+
fn transform_stream_chunk(
68+
&self,
69+
raw: &str,
70+
state: &mut ChatStreamState,
71+
) -> Result<Vec<ChatCompletionChunk>> {
72+
parse_anthropic_sse_to_openai(raw, state)
73+
}
74+
}
75+
76+
impl ProviderCapabilities for AnthropicDef {
77+
fn as_native_anthropic_messages(&self) -> Option<&dyn NativeAnthropicMessagesSupport> {
78+
Some(self)
79+
}
80+
}
81+
82+
impl NativeAnthropicMessagesSupport for AnthropicDef {
83+
fn native_anthropic_messages_endpoint(&self, _model: &str) -> Cow<'static, str> {
84+
Cow::Borrowed("/v1/messages")
85+
}
86+
87+
fn transform_anthropic_messages_request(
88+
&self,
89+
req: &AnthropicMessagesRequest,
90+
) -> Result<Value> {
91+
serde_json::to_value(req).map_err(|error| GatewayError::Transform(error.to_string()))
92+
}
93+
94+
fn transform_anthropic_messages_response(
95+
&self,
96+
body: Value,
97+
) -> Result<AnthropicMessagesResponse> {
98+
serde_json::from_value(body).map_err(|error| GatewayError::Transform(error.to_string()))
99+
}
100+
101+
fn transform_anthropic_messages_stream_chunk(
102+
&self,
103+
raw: &str,
104+
_state: &mut AnthropicMessagesNativeStreamState,
105+
) -> Result<Vec<AnthropicStreamEvent>> {
106+
parse_anthropic_native_sse(raw)
107+
}
108+
}
109+
110+
#[cfg(test)]
111+
mod tests {
112+
use serde_json::json;
113+
114+
use super::AnthropicDef;
115+
use crate::gateway::{
116+
provider_instance::ProviderAuth,
117+
traits::{
118+
AnthropicMessagesNativeStreamState, ChatTransform, NativeAnthropicMessagesSupport,
119+
ProviderCapabilities, ProviderMeta,
120+
},
121+
types::anthropic::{AnthropicMessagesRequest, AnthropicStreamEvent},
122+
};
123+
124+
#[test]
125+
fn anthropic_def_builds_expected_headers_and_registers_native_support() {
126+
let provider = AnthropicDef;
127+
let headers = provider
128+
.build_auth_headers(&ProviderAuth::ApiKey("sk-ant".into()))
129+
.unwrap();
130+
131+
assert_eq!(provider.name(), "anthropic");
132+
assert_eq!(provider.default_base_url(), "https://api.anthropic.com");
133+
assert_eq!(provider.chat_endpoint_path("ignored"), "/v1/messages");
134+
assert_eq!(headers["x-api-key"], "sk-ant");
135+
assert_eq!(headers["anthropic-version"], "2023-06-01");
136+
assert!(provider.as_native_anthropic_messages().is_some());
137+
}
138+
139+
#[test]
140+
fn native_anthropic_passthrough_serializes_and_parses() {
141+
let provider = AnthropicDef;
142+
let request: AnthropicMessagesRequest = serde_json::from_value(json!({
143+
"model": "claude-3-5-sonnet-20241022",
144+
"max_tokens": 1024,
145+
"messages": [{"role": "user", "content": "Hello"}]
146+
}))
147+
.unwrap();
148+
149+
let body = provider
150+
.transform_anthropic_messages_request(&request)
151+
.unwrap();
152+
let parsed = provider
153+
.transform_anthropic_messages_response(json!({
154+
"id": "msg_123",
155+
"type": "message",
156+
"role": "assistant",
157+
"content": [{"type": "text", "text": "Hello"}],
158+
"model": "claude-3-5-sonnet-20241022",
159+
"stop_reason": "end_turn",
160+
"stop_sequence": null,
161+
"usage": {"input_tokens": 1, "output_tokens": 2}
162+
}))
163+
.unwrap();
164+
let events = provider
165+
.transform_anthropic_messages_stream_chunk(
166+
r#"data: {"type":"ping"}"#,
167+
&mut AnthropicMessagesNativeStreamState,
168+
)
169+
.unwrap();
170+
171+
assert_eq!(body["model"], "claude-3-5-sonnet-20241022");
172+
assert_eq!(parsed.id, "msg_123");
173+
assert!(matches!(events.as_slice(), [AnthropicStreamEvent::Ping]));
174+
}
175+
176+
#[test]
177+
fn transform_request_and_response_bridge_through_anthropic_def() {
178+
let provider = AnthropicDef;
179+
let body = provider
180+
.transform_request(
181+
&serde_json::from_value(json!({
182+
"model": "claude-3-5-sonnet-20241022",
183+
"messages": [{"role": "user", "content": "Hello"}]
184+
}))
185+
.unwrap(),
186+
)
187+
.unwrap();
188+
let response = provider
189+
.transform_response(json!({
190+
"id": "msg_123",
191+
"type": "message",
192+
"role": "assistant",
193+
"content": [{"type": "text", "text": "Hello"}],
194+
"model": "claude-3-5-sonnet-20241022",
195+
"stop_reason": "end_turn",
196+
"stop_sequence": null,
197+
"usage": {"input_tokens": 1, "output_tokens": 2}
198+
}))
199+
.unwrap();
200+
201+
assert_eq!(body["model"], "claude-3-5-sonnet-20241022");
202+
assert_eq!(response.choices[0].message.role, "assistant");
203+
assert_eq!(
204+
response.choices[0].message.content.as_ref().map(|_| true),
205+
Some(true)
206+
);
207+
}
208+
}

0 commit comments

Comments
 (0)