feat: Support Responses API fallback for OpenAI-compatible providers (#134)
- Add new structs for Responses API request/response format - Add helper functions for extracting text from Responses API responses - Refactor auth header application into a shared apply_auth_header method - When chat completions returns 404 NOT_FOUND, fall back to Responses API - Add tests for Responses API text extraction This enables compatibility with providers that implement the Responses API instead of Chat Completions (e.g., some newer Groq models). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1e21c24e1b
commit
1eadd88cf5
1 changed files with 174 additions and 16 deletions
|
|
@ -73,6 +73,129 @@ struct ResponseMessage {
|
||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ResponsesRequest {
|
||||||
|
model: String,
|
||||||
|
input: Vec<ResponsesInput>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
instructions: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
stream: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ResponsesInput {
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResponsesResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
output: Vec<ResponsesOutput>,
|
||||||
|
#[serde(default)]
|
||||||
|
output_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResponsesOutput {
|
||||||
|
#[serde(default)]
|
||||||
|
content: Vec<ResponsesContent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResponsesContent {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: Option<String>,
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_nonempty(text: Option<&str>) -> Option<String> {
|
||||||
|
text.and_then(|value| {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_responses_text(response: ResponsesResponse) -> Option<String> {
|
||||||
|
if let Some(text) = first_nonempty(response.output_text.as_deref()) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in &response.output {
|
||||||
|
for content in &item.content {
|
||||||
|
if content.kind.as_deref() == Some("output_text") {
|
||||||
|
if let Some(text) = first_nonempty(content.text.as_deref()) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in &response.output {
|
||||||
|
for content in &item.content {
|
||||||
|
if let Some(text) = first_nonempty(content.text.as_deref()) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenAiCompatibleProvider {
|
||||||
|
fn apply_auth_header(
|
||||||
|
&self,
|
||||||
|
req: reqwest::RequestBuilder,
|
||||||
|
api_key: &str,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
match &self.auth_header {
|
||||||
|
AuthStyle::Bearer => req.header("Authorization", format!("Bearer {api_key}")),
|
||||||
|
AuthStyle::XApiKey => req.header("x-api-key", api_key),
|
||||||
|
AuthStyle::Custom(header) => req.header(header, api_key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_via_responses(
|
||||||
|
&self,
|
||||||
|
api_key: &str,
|
||||||
|
system_prompt: Option<&str>,
|
||||||
|
message: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let request = ResponsesRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
input: vec![ResponsesInput {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: message.to_string(),
|
||||||
|
}],
|
||||||
|
instructions: system_prompt.map(str::to_string),
|
||||||
|
stream: Some(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/v1/responses", self.base_url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.apply_auth_header(self.client.post(&url).json(&request), api_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error = response.text().await?;
|
||||||
|
anyhow::bail!("{} Responses API error: {error}", self.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responses: ResponsesResponse = response.json().await?;
|
||||||
|
|
||||||
|
extract_responses_text(responses)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No response from {} Responses API", self.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Provider for OpenAiCompatibleProvider {
|
impl Provider for OpenAiCompatibleProvider {
|
||||||
async fn chat_with_system(
|
async fn chat_with_system(
|
||||||
|
|
@ -111,24 +234,28 @@ impl Provider for OpenAiCompatibleProvider {
|
||||||
|
|
||||||
let url = format!("{}/v1/chat/completions", self.base_url);
|
let url = format!("{}/v1/chat/completions", self.base_url);
|
||||||
|
|
||||||
let mut req = self.client.post(&url).json(&request);
|
let response = self
|
||||||
|
.apply_auth_header(self.client.post(&url).json(&request), api_key)
|
||||||
match &self.auth_header {
|
.send()
|
||||||
AuthStyle::Bearer => {
|
.await?;
|
||||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
|
||||||
}
|
|
||||||
AuthStyle::XApiKey => {
|
|
||||||
req = req.header("x-api-key", api_key.as_str());
|
|
||||||
}
|
|
||||||
AuthStyle::Custom(header) => {
|
|
||||||
req = req.header(header.as_str(), api_key.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = req.send().await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(super::api_error(&self.name, response).await);
|
let status = response.status();
|
||||||
|
let error = response.text().await?;
|
||||||
|
|
||||||
|
if status == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return self
|
||||||
|
.chat_via_responses(api_key, system_prompt, message, model)
|
||||||
|
.await
|
||||||
|
.map_err(|responses_err| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"{} API error: {error} (chat completions unavailable; responses fallback failed: {responses_err})",
|
||||||
|
self.name
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!("{} API error: {error}", self.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let chat_response: ChatResponse = response.json().await?;
|
let chat_response: ChatResponse = response.json().await?;
|
||||||
|
|
@ -263,4 +390,35 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_extracts_top_level_output_text() {
|
||||||
|
let json = r#"{"output_text":"Hello from top-level","output":[]}"#;
|
||||||
|
let response: ResponsesResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
extract_responses_text(response).as_deref(),
|
||||||
|
Some("Hello from top-level")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_extracts_nested_output_text() {
|
||||||
|
let json =
|
||||||
|
r#"{"output":[{"content":[{"type":"output_text","text":"Hello from nested"}]}]}"#;
|
||||||
|
let response: ResponsesResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
extract_responses_text(response).as_deref(),
|
||||||
|
Some("Hello from nested")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_extracts_any_text_as_fallback() {
|
||||||
|
let json = r#"{"output":[{"content":[{"type":"message","text":"Fallback text"}]}]}"#;
|
||||||
|
let response: ResponsesResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
extract_responses_text(response).as_deref(),
|
||||||
|
Some("Fallback text")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue