llm: add structured streaming support for Anthropic and OpenAI clients
Nightly Build / build (push) Successful in 6m46s
Nightly Build / build (push) Successful in 6m46s
This commit is contained in:
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, SseDecoder, StreamDelta, ToolCall, headers_to_json, redact_key};
|
||||
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, SseDecoder, StreamDelta, ToolCall, error_response_body, headers_to_json, redact_key};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
@@ -203,6 +203,10 @@ impl AnthropicClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends the request and returns the raw response **without** `error_for_status`,
|
||||
/// so the tool-calling paths can read the error body and attach the request
|
||||
/// payload to the `LlmError` (a `reqwest` status error discards the body). The
|
||||
/// plain `chat` path keeps its own `error_for_status`.
|
||||
async fn send_request(&self, body: &Value) -> reqwest::Result<reqwest::Response> {
|
||||
self.http
|
||||
.post(self.url())
|
||||
@@ -211,8 +215,7 @@ impl AnthropicClient {
|
||||
.header("X-Title", core_api::APP_NAME)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Joined `thinking` blocks of a content array, if any (extended thinking).
|
||||
@@ -252,6 +255,23 @@ impl AnthropicClient {
|
||||
|
||||
let http_resp = self.send_request(&body).await?;
|
||||
let response_headers = headers_to_json(http_resp.headers());
|
||||
let status = http_resp.status();
|
||||
if !status.is_success() {
|
||||
let resp_text = http_resp.text().await?;
|
||||
return Err(crate::LlmError {
|
||||
status: Some(status.as_u16()),
|
||||
message: format!(
|
||||
"anthropic: HTTP {status} from {url}\nbody: {resp_text}",
|
||||
url = self.url(),
|
||||
),
|
||||
raw_meta: Some(LlmRawMeta {
|
||||
request_headers: Some(request_headers),
|
||||
request_body: Some(request_body),
|
||||
response_headers: Some(response_headers),
|
||||
response_body: Some(error_response_body(resp_text)),
|
||||
}),
|
||||
}.into());
|
||||
}
|
||||
|
||||
/// One content block being accumulated by index.
|
||||
#[derive(Default)]
|
||||
@@ -562,7 +582,23 @@ impl ChatbotClient for AnthropicClient {
|
||||
let http_resp = self.send_request(&body).await?;
|
||||
|
||||
let response_headers = headers_to_json(http_resp.headers());
|
||||
let status = http_resp.status();
|
||||
let resp_text = http_resp.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(crate::LlmError {
|
||||
status: Some(status.as_u16()),
|
||||
message: format!(
|
||||
"anthropic: HTTP {status} from {url}\nbody: {resp_text}",
|
||||
url = self.url(),
|
||||
),
|
||||
raw_meta: Some(LlmRawMeta {
|
||||
request_headers: Some(request_headers),
|
||||
request_body: Some(request_body),
|
||||
response_headers: Some(response_headers),
|
||||
response_body: Some(error_response_body(resp_text)),
|
||||
}),
|
||||
}.into());
|
||||
}
|
||||
let resp: Value = serde_json::from_str(&resp_text)
|
||||
.map_err(|e| anyhow::anyhow!("anthropic: failed to parse response JSON: {e}\nbody: {resp_text}"))?;
|
||||
let response_body: Value = serde_json::from_str(&resp_text).unwrap_or(Value::Null);
|
||||
|
||||
@@ -66,6 +66,14 @@ pub fn headers_to_json(headers: &reqwest::header::HeaderMap) -> Value {
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
/// Turns a raw error-response body into a JSON `Value` for the payload log:
|
||||
/// the parsed JSON when the provider returned JSON (the common case — an
|
||||
/// `{"error": …}` object), else the raw text wrapped as a JSON string so a
|
||||
/// non-JSON body (HTML gateway page, plain text) is still preserved verbatim.
|
||||
pub fn error_response_body(text: String) -> Value {
|
||||
serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text))
|
||||
}
|
||||
|
||||
/// Returns a redacted preview of an API key: first 7 chars + "***".
|
||||
pub fn redact_key(key: &str) -> String {
|
||||
if key.len() > 7 {
|
||||
@@ -82,12 +90,17 @@ pub fn redact_key(key: &str) -> String {
|
||||
/// substring-matching a formatted message — which mis-fires when a model id, token
|
||||
/// count or URL merely contains "401"/"404"/… (bug B6). Non-HTTP failures (network,
|
||||
/// JSON parse, cancellation) stay ordinary `anyhow` errors with no status.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LlmError {
|
||||
/// HTTP status code, when the failure came from an HTTP response.
|
||||
pub status: Option<u16>,
|
||||
/// Human-readable detail (provider tag + body), used for logs and the UI.
|
||||
pub message: String,
|
||||
/// Request/response payload captured at the failing call, so the debug log
|
||||
/// can show what was actually sent even when the provider rejected it (e.g.
|
||||
/// a 400). `None` for failures with no HTTP round-trip (network, cancellation,
|
||||
/// parse) — those carry no body to surface.
|
||||
pub raw_meta: Option<LlmRawMeta>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LlmError {
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, SseDecoder, StreamDelta, ToolCall, headers_to_json, redact_key};
|
||||
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, SseDecoder, StreamDelta, ToolCall, error_response_body, headers_to_json, redact_key};
|
||||
use core_api::APP_NAME;
|
||||
|
||||
/// OpenAI ChatGPT client (also compatible with any OpenAI-spec endpoint).
|
||||
@@ -140,6 +140,12 @@ impl OpenAiClient {
|
||||
"openai: HTTP {status} from {url}\nbody: {resp_text}",
|
||||
url = self.url(),
|
||||
),
|
||||
raw_meta: Some(LlmRawMeta {
|
||||
request_headers: Some(request_headers),
|
||||
request_body: Some(request_body),
|
||||
response_headers: Some(response_headers),
|
||||
response_body: Some(error_response_body(resp_text)),
|
||||
}),
|
||||
}.into());
|
||||
}
|
||||
|
||||
@@ -348,6 +354,12 @@ impl ChatbotClient for OpenAiClient {
|
||||
"openai: HTTP {status} from {url}\nbody: {resp_text}",
|
||||
url = self.url(),
|
||||
),
|
||||
raw_meta: Some(LlmRawMeta {
|
||||
request_headers: Some(request_headers),
|
||||
request_body: Some(request_body),
|
||||
response_headers: Some(response_headers),
|
||||
response_body: Some(error_response_body(resp_text)),
|
||||
}),
|
||||
}.into());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user