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:
Argenis 2026-02-15 09:03:42 -05:00 committed by GitHub
parent 1e21c24e1b
commit 1eadd88cf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -73,6 +73,129 @@ struct ResponseMessage {
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]
impl Provider for OpenAiCompatibleProvider {
async fn chat_with_system(
@ -111,24 +234,28 @@ impl Provider for OpenAiCompatibleProvider {
let url = format!("{}/v1/chat/completions", self.base_url);
let mut req = self.client.post(&url).json(&request);
match &self.auth_header {
AuthStyle::Bearer => {
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?;
let response = self
.apply_auth_header(self.client.post(&url).json(&request), api_key)
.send()
.await?;
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?;
@ -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")
);
}
}