feat(claw): pass image parts through the OpenAI shim
The shim's last user message now cuts the image_url parts along with the text: an inline data URI with a parseable mime rides to the turn as an image block, a remote http(s) url and an unknown mime are skipped, and the messages route's image budget applies. Text-only requests are unchanged.
This commit is contained in:
1 parent
1323e69dc4
commit
23b13f41b8
3 files changed
+253
-15
No files matched your search
@@ -289,7 +289,11 @@ pub fn router(state: ClawState) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/providers/{id}", delete(providers_remove))
|
.route("/providers/{id}", delete(providers_remove))
|
||||||
.route("/providers/{id}/models", get(providers_models))
|
.route("/providers/{id}/models", get(providers_models))
|
||||||
.route("/chat/completions", post(crate::openai::chat_completions))
|
.route(
|
||||||
|
"/chat/completions",
|
||||||
|
post(crate::openai::chat_completions)
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(MAX_MESSAGE_BODY_BYTES)),
|
||||||
|
)
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), require_auth));
|
.layer(middleware::from_fn_with_state(state.clone(), require_auth));
|
||||||
Router::new()
|
Router::new()
|
||||||
// The one error shape everywhere, unknown routes included: a
|
// The one error shape everywhere, unknown routes included: a
|
||||||
|
|||||||
+150
-14
@@ -17,6 +17,12 @@
|
|||||||
//! `delta.content` for text, a final chunk with `finish_reason: "stop"`,
|
//! `delta.content` for text, a final chunk with `finish_reason: "stop"`,
|
||||||
//! then `data: [DONE]`); `stream: false` answers one completion object
|
//! then `data: [DONE]`); `stream: false` answers one completion object
|
||||||
//! once the turn's end marker arrives.
|
//! once the turn's end marker arrives.
|
||||||
|
//!
|
||||||
|
//! The last user message may carry OpenAI's image parts
|
||||||
|
//! (`{"type": "image_url", "image_url": {"url": ...}}`). Only inline
|
||||||
|
//! `data:` URIs ride — the crate carries images inline, so a remote
|
||||||
|
//! http(s) url is skipped — and the same budget as the messages route
|
||||||
|
//! applies.
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -25,6 +31,7 @@ use axum::extract::State;
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use rhema_core::ImageMediaType;
|
||||||
use rhema_rsd::{KeyState, ResourceKey};
|
use rhema_rsd::{KeyState, ResourceKey};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -32,7 +39,7 @@ use tokio::sync::broadcast::error::RecvError;
|
|||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
use crate::hub::TurnEvent;
|
use crate::hub::TurnEvent;
|
||||||
use crate::interactive::KEY_PREFIX;
|
use crate::interactive::{KEY_PREFIX, WireImage};
|
||||||
|
|
||||||
/// How long the non-stream path waits for the turn's end marker. Turns
|
/// How long the non-stream path waits for the turn's end marker. Turns
|
||||||
/// can legitimately run minutes of shell work; a web UI's default
|
/// can legitimately run minutes of shell work; a web UI's default
|
||||||
@@ -90,6 +97,17 @@ impl ChatContent {
|
|||||||
.join(""),
|
.join(""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The message's inline images, in part order: every part whose
|
||||||
|
/// `image_url` is a `data:` URI with a parseable image mime. A
|
||||||
|
/// remote url and an unknown mime are skipped, not errors — the
|
||||||
|
/// crate carries images inline only.
|
||||||
|
pub fn images(&self) -> Vec<WireImage> {
|
||||||
|
match self {
|
||||||
|
ChatContent::Text(_) => Vec::new(),
|
||||||
|
ChatContent::Parts(parts) => parts.iter().filter_map(|part| part.image()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One content part; unknown parts carry no readable text.
|
/// One content part; unknown parts carry no readable text.
|
||||||
@@ -98,15 +116,56 @@ pub struct ChatPart {
|
|||||||
/// The part's text, for `{"type": "text"}` parts.
|
/// The part's text, for `{"type": "text"}` parts.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
/// The part's image reference, for `{"type": "image_url"}` parts.
|
||||||
|
#[serde(default)]
|
||||||
|
pub image_url: Option<ChatImageUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The last `user` message's text — the only input a turn gets.
|
/// OpenAI's image part payload: `{"url": ...}`.
|
||||||
pub fn last_user_text(messages: &[ChatMessage]) -> Option<String> {
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatImageUrl {
|
||||||
|
/// A `data:{mime};base64,{body}` URI is the one shape the claw
|
||||||
|
/// carries; anything else parses as no image at all.
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatPart {
|
||||||
|
/// The part's inline image, when it is one: an `image_url` part
|
||||||
|
/// carrying a `data:{mime};base64,{body}` URI whose mime parses as
|
||||||
|
/// an image format. The body rides as bare base64, exactly what
|
||||||
|
/// the provider APIs take.
|
||||||
|
fn image(&self) -> Option<WireImage> {
|
||||||
|
let uri = self.image_url.as_ref()?.url.trim();
|
||||||
|
let rest = uri.strip_prefix("data:")?;
|
||||||
|
let (meta, body) = rest.split_once(',')?;
|
||||||
|
let mime = meta.strip_suffix(";base64")?;
|
||||||
|
let media_type = ImageMediaType::parse(mime)?;
|
||||||
|
Some(WireImage {
|
||||||
|
media_type: media_type.as_str().to_owned(),
|
||||||
|
data: body.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The input one turn runs on, as the shim cut it from the request's
|
||||||
|
/// last `user` entry.
|
||||||
|
pub struct UserInput {
|
||||||
|
/// The text parts, joined.
|
||||||
|
pub text: String,
|
||||||
|
/// The inline images, in part order.
|
||||||
|
pub images: Vec<WireImage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last `user` message's input — the only input a turn gets.
|
||||||
|
pub fn last_user_input(messages: &[ChatMessage]) -> Option<UserInput> {
|
||||||
messages
|
messages
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|message| message.role == "user")
|
.find(|message| message.role == "user")
|
||||||
.map(|message| message.content.text())
|
.map(|message| UserInput {
|
||||||
|
text: message.content.text(),
|
||||||
|
images: message.content.images(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The completion/chunk id: `chatcmpl-` plus a process-unique suffix.
|
/// The completion/chunk id: `chatcmpl-` plus a process-unique suffix.
|
||||||
@@ -168,12 +227,18 @@ pub async fn chat_completions(
|
|||||||
State(state): State<crate::http::ClawState>,
|
State(state): State<crate::http::ClawState>,
|
||||||
Json(request): Json<ChatRequest>,
|
Json(request): Json<ChatRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let Some(text) = last_user_text(&request.messages).filter(|t| !t.trim().is_empty()) else {
|
let Some(input) = last_user_input(&request.messages).filter(|i| !i.text.trim().is_empty())
|
||||||
|
else {
|
||||||
return error_response(
|
return error_response(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"no usable user message: the shim reads only the last `user` entry",
|
"no usable user message: the shim reads only the last `user` entry",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
// The same image budget the messages route keeps; the message must
|
||||||
|
// still carry text, so an images-only request is refused above.
|
||||||
|
if let Err(reason) = crate::http::validate_images(&input.images) {
|
||||||
|
return error_response(StatusCode::BAD_REQUEST, &reason);
|
||||||
|
}
|
||||||
let user = request
|
let user = request
|
||||||
.user
|
.user
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -191,11 +256,11 @@ pub async fn chat_completions(
|
|||||||
// Subscribe first, ingest second, drain third — the same contract
|
// Subscribe first, ingest second, drain third — the same contract
|
||||||
// the messages route keeps.
|
// the messages route keeps.
|
||||||
let mut receiver = state.hub().subscribe(&key);
|
let mut receiver = state.hub().subscribe(&key);
|
||||||
if let Err(err) =
|
if let Err(err) = state.dispatcher().ingest(crate::interactive::user_event(
|
||||||
state
|
key.as_str(),
|
||||||
.dispatcher()
|
&input.text,
|
||||||
.ingest(crate::interactive::user_event(key.as_str(), &text, &[]))
|
&input.images,
|
||||||
{
|
)) {
|
||||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
||||||
}
|
}
|
||||||
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await {
|
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await {
|
||||||
@@ -360,9 +425,14 @@ mod tests {
|
|||||||
message("assistant", ChatContent::Text("no".into())),
|
message("assistant", ChatContent::Text("no".into())),
|
||||||
message("user", ChatContent::Text("second".into())),
|
message("user", ChatContent::Text("second".into())),
|
||||||
];
|
];
|
||||||
assert_eq!(last_user_text(&messages).as_deref(), Some("second"));
|
let read = |slice: &[ChatMessage]| {
|
||||||
assert_eq!(last_user_text(&messages[0..1]).as_deref(), None);
|
last_user_input(slice)
|
||||||
assert_eq!(last_user_text(&[]), None);
|
.filter(|input| !input.text.trim().is_empty())
|
||||||
|
.map(|input| input.text)
|
||||||
|
};
|
||||||
|
assert_eq!(read(&messages).as_deref(), Some("second"));
|
||||||
|
assert_eq!(read(&messages[0..1]), None);
|
||||||
|
assert_eq!(read(&[]), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -370,18 +440,84 @@ mod tests {
|
|||||||
let parts = ChatContent::Parts(vec![
|
let parts = ChatContent::Parts(vec![
|
||||||
ChatPart {
|
ChatPart {
|
||||||
text: Some("look ".into()),
|
text: Some("look ".into()),
|
||||||
|
image_url: None,
|
||||||
},
|
},
|
||||||
ChatPart {
|
ChatPart {
|
||||||
text: None, // an image part, say
|
text: None,
|
||||||
|
image_url: Some(ChatImageUrl {
|
||||||
|
url: "data:image/png;base64,cG5n".into(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
ChatPart {
|
ChatPart {
|
||||||
text: Some("here".into()),
|
text: Some("here".into()),
|
||||||
|
image_url: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(parts.text(), "look here");
|
assert_eq!(parts.text(), "look here");
|
||||||
assert_eq!(ChatContent::Text("plain".into()).text(), "plain");
|
assert_eq!(ChatContent::Text("plain".into()).text(), "plain");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An `image_url` part rides only as an inline data URI with a mime
|
||||||
|
/// the providers take: a remote url, a non-base64 data URI, and an
|
||||||
|
/// unknown mime are all skipped, in part order, with no error.
|
||||||
|
#[test]
|
||||||
|
fn image_parts_ride_only_as_inline_data_uris() {
|
||||||
|
let content = ChatContent::Parts(vec![
|
||||||
|
ChatPart {
|
||||||
|
text: Some("look".into()),
|
||||||
|
image_url: None,
|
||||||
|
},
|
||||||
|
ChatPart {
|
||||||
|
text: None,
|
||||||
|
image_url: Some(ChatImageUrl {
|
||||||
|
url: "data:image/png;base64,cG5n".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ChatPart {
|
||||||
|
text: None,
|
||||||
|
image_url: Some(ChatImageUrl {
|
||||||
|
url: "https://example.com/cat.png".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ChatPart {
|
||||||
|
text: None,
|
||||||
|
image_url: Some(ChatImageUrl {
|
||||||
|
url: "data:image/svg+xml;base64,PHN2Zy8+".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ChatPart {
|
||||||
|
text: None,
|
||||||
|
image_url: Some(ChatImageUrl {
|
||||||
|
url: "data:image/png,raw-not-base64".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let images = content.images();
|
||||||
|
assert_eq!(images.len(), 1, "{images:?}");
|
||||||
|
assert_eq!(images[0].media_type, "image/png");
|
||||||
|
assert_eq!(images[0].data, "cG5n");
|
||||||
|
|
||||||
|
// A plain string carries no images, and the mime is canonical
|
||||||
|
// even when the URI spells it with different case.
|
||||||
|
assert!(ChatContent::Text("plain".into()).images().is_empty());
|
||||||
|
let loud = ChatContent::Parts(vec![ChatPart {
|
||||||
|
text: None,
|
||||||
|
image_url: Some(ChatImageUrl {
|
||||||
|
url: "data:IMAGE/PNG;base64,cG5n".into(),
|
||||||
|
}),
|
||||||
|
}]);
|
||||||
|
assert_eq!(loud.images()[0].media_type, "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text-only requests are unchanged: no parts, no images.
|
||||||
|
#[test]
|
||||||
|
fn text_only_requests_carry_no_images() {
|
||||||
|
let input = last_user_input(&[message("user", ChatContent::Text("plain".into()))])
|
||||||
|
.expect("a user message");
|
||||||
|
assert_eq!(input.text, "plain");
|
||||||
|
assert!(input.images.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_completion_shape_is_the_familiar_one() {
|
fn the_completion_shape_is_the_familiar_one() {
|
||||||
let body = completion_body("chatcmpl-1", 42, "m", Some("hi".into()));
|
let body = completion_body("chatcmpl-1", 42, "m", Some("hi".into()));
|
||||||
|
|||||||
@@ -110,6 +110,48 @@ async fn sse_body(response: reqwest::Response) -> SseBody {
|
|||||||
Box::pin(response.bytes_stream())
|
Box::pin(response.bytes_stream())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A provider that keeps every request it is handed, so a test can
|
||||||
|
/// assert on what the model would have seen.
|
||||||
|
struct RecordingProvider {
|
||||||
|
requests: std::sync::Mutex<Vec<rhema_core::ModelRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingProvider {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
requests: std::sync::Mutex::new(Vec::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last request's user-turn content blocks, in order.
|
||||||
|
fn last_user_content(&self) -> Vec<Content> {
|
||||||
|
let requests = self.requests.lock().unwrap();
|
||||||
|
let last = requests.last().expect("a request was made");
|
||||||
|
last.messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|message| message.role == Role::User)
|
||||||
|
.map(|message| message.content.clone())
|
||||||
|
.expect("a user turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rhema_autonomous_agent::async_trait]
|
||||||
|
impl rhema_core::ModelProvider for RecordingProvider {
|
||||||
|
async fn complete(
|
||||||
|
&self,
|
||||||
|
request: rhema_core::ModelRequest,
|
||||||
|
_sink: &mut dyn rhema_core::EventSink,
|
||||||
|
) -> Result<rhema_core::ModelOutput, rhema_core::ProviderError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
|
Ok(ModelOutput {
|
||||||
|
message: Message::text(Role::Assistant, "noted."),
|
||||||
|
stop_reason: StopReason::EndTurn,
|
||||||
|
usage: Usage::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One quiet GitHub repo, for the watches test: an issues list with a
|
/// One quiet GitHub repo, for the watches test: an issues list with a
|
||||||
/// single closed issue and a quiet comments feed.
|
/// single closed issue and a quiet comments feed.
|
||||||
async fn mount_github(server: &MockServer) {
|
async fn mount_github(server: &MockServer) {
|
||||||
@@ -918,6 +960,62 @@ async fn the_shim_rejects_requests_without_a_user_message() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A shim request whose user content is parts — text plus `image_url`
|
||||||
|
/// entries — reaches the turn as text and inline image blocks, the
|
||||||
|
/// base64 verbatim. A remote url is skipped: the crate carries inline
|
||||||
|
/// images only.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_shim_image_part_reaches_the_turn_as_an_image_block() {
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
let recorder = RecordingProvider::new();
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let daemon = Daemon::build(
|
||||||
|
&config(&dir, None),
|
||||||
|
mock_registry(recorder.clone()),
|
||||||
|
rhema_rsd::DispatcherConfig::default(),
|
||||||
|
)
|
||||||
|
.expect("the daemon assembles");
|
||||||
|
let base = serve(&daemon).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let raw: &[u8] = b"\x89PNG shim bytes";
|
||||||
|
let png = base64::engine::general_purpose::STANDARD.encode(raw);
|
||||||
|
let whole: Value = client
|
||||||
|
.post(format!("{base}/v1/chat/completions"))
|
||||||
|
.json(&json!({
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "look"},
|
||||||
|
{"type": "image_url",
|
||||||
|
"image_url": {"url": format!("data:image/png;base64,{png}")}},
|
||||||
|
{"type": "image_url",
|
||||||
|
"image_url": {"url": "https://example.com/cat.png"}},
|
||||||
|
{"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/svg+xml;base64,PHN2Zy8+"}},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("the completion posts")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("the completion parses");
|
||||||
|
assert_eq!(whole["choices"][0]["message"]["content"], "noted.");
|
||||||
|
|
||||||
|
// The turn's input: the text, then the one inline image — the
|
||||||
|
// remote url and the unknown mime never rode.
|
||||||
|
assert_eq!(
|
||||||
|
recorder.last_user_content(),
|
||||||
|
vec![
|
||||||
|
Content::Text("look".to_owned()),
|
||||||
|
Content::Image(rhema_core::Image::new(rhema_core::ImageMediaType::Png, png,)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The bootstrap badge as the route serves it for `daemon`, with the
|
/// The bootstrap badge as the route serves it for `daemon`, with the
|
||||||
/// config's bearer.
|
/// config's bearer.
|
||||||
async fn bootstrap_badge(daemon: &Daemon) -> Value {
|
async fn bootstrap_badge(daemon: &Daemon) -> Value {
|
||||||
|
|||||||
Reference in new issue
Block a user