Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions bindings/typescript/src/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ type ImportSpan = {
[key: string]: unknown;
};

export type ImportedSpanData = {
messages: Message[];
metadata?: unknown;
};

type GoogleWasmExports = {
google_contents_to_lingua: (value: unknown) => unknown;
lingua_to_google_contents: (value: unknown) => unknown;
Expand Down Expand Up @@ -382,6 +387,29 @@ export function importMessagesFromSpans(
}
}

/**
* Import messages and normalized metadata from logging spans by parsing input/output fields.
*
* The returned `metadata` is provider-normalized request metadata when the importer can infer it.
* Today this is used for OpenAI Responses spans so the UI can consume chat-completions-style params
* without running a separate TypeScript-side metadata transform.
*/
export function importSpanDataFromSpans(
spans: ImportSpan[]
): ImportedSpanData {
try {
const result = getWasm().import_span_data_from_spans(spans);
return convertMapsToObjects(result) as ImportedSpanData;
} catch (error: unknown) {
throw new ConversionError(
"Failed to import span data from spans",
undefined,
undefined,
error
);
}
}

/**
* Import and deduplicate messages from spans in a single operation
*
Expand Down
7 changes: 6 additions & 1 deletion bindings/typescript/src/wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
// Processing functions
deduplicateMessages,
importMessagesFromSpans,
importSpanDataFromSpans,
importAndDeduplicateMessages,

// Chat Completions validation
Expand All @@ -45,4 +46,8 @@ export {
} from "./converters";

// Re-export types
export type { ValidationResult, TransformStreamChunkResult } from "./converters";
export type {
ImportedSpanData,
ValidationResult,
TransformStreamChunkResult,
} from "./converters";
80 changes: 74 additions & 6 deletions crates/lingua/src/processing/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ use crate::providers::bedrock::convert::try_parse_bedrock_for_import;
use crate::providers::google::convert::try_parse_google_for_import;
#[cfg(feature = "openai")]
use crate::providers::openai::convert::{
try_parse_openai_for_import, try_system_message_from_openai_metadata,
ChatCompletionRequestMessageExt,
try_parse_openai_for_import, ChatCompletionRequestMessageExt,
};
#[cfg(feature = "openai")]
use crate::providers::openai::params::{
normalize_openai_responses_metadata_for_chat_completions,
try_system_message_from_openai_metadata,
};
use crate::serde_json;
use crate::serde_json::Value;
Expand All @@ -35,6 +39,13 @@ pub struct Span {
pub other: serde_json::Map<String, Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportedSpanData {
pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}

/// Try to convert a value to lingua messages by attempting multiple format conversions
fn try_converting_to_messages(data: &Value) -> Vec<Message> {
if is_role_message_array(data) {
Expand Down Expand Up @@ -587,8 +598,9 @@ fn try_choices_array_parsing(data: &Value) -> Option<Vec<Message>> {
///
/// This function processes spans and extracts messages from their input/output fields,
/// attempting to convert them from various provider formats to the lingua format.
pub fn import_messages_from_spans(spans: Vec<Span>) -> Vec<Message> {
pub fn import_span_data_from_spans(spans: Vec<Span>) -> ImportedSpanData {
let mut messages = Vec::new();
let mut normalized_metadata = None;

for span in spans {
let mut span_messages = Vec::new();
Expand All @@ -604,15 +616,20 @@ pub fn import_messages_from_spans(spans: Vec<Span>) -> Vec<Message> {
}

#[cfg(feature = "openai")]
if let Some(metadata) = span.other.get("metadata") {
if let Some(system_message) = try_system_message_from_openai_metadata(metadata) {
if let Some(span_metadata) = span.other.get("metadata") {
if let Some(system_message) = try_system_message_from_openai_metadata(span_metadata) {
let has_system_message = span_messages
.iter()
.any(|message| matches!(message, Message::System { .. }));
if !has_system_message {
span_messages.insert(0, system_message);
}
}

if normalized_metadata.is_none() {
normalized_metadata =
normalize_openai_responses_metadata_for_chat_completions(span_metadata);
}
}

messages.extend(span_messages);
Expand All @@ -631,11 +648,62 @@ pub fn import_messages_from_spans(spans: Vec<Span>) -> Vec<Message> {
}
}

messages
ImportedSpanData {
messages,
metadata: normalized_metadata,
}
}

pub fn import_messages_from_spans(spans: Vec<Span>) -> Vec<Message> {
import_span_data_from_spans(spans).messages
}

/// Import and deduplicate messages from spans in a single operation
pub fn import_and_deduplicate_messages(spans: Vec<Span>) -> Vec<Message> {
let messages = import_messages_from_spans(spans);
super::dedup::deduplicate_messages(messages)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::serde_json::json;

#[test]
fn test_import_span_data_from_spans_normalizes_openai_responses_metadata() {
let imported = import_span_data_from_spans(vec![Span {
input: Some(json!([{ "role": "user", "content": "hello" }])),
output: None,
other: serde_json::Map::from_iter([(
"metadata".into(),
json!({
"object": "response",
"id": "resp_123",
"tools": [{
"type": "function",
"name": "lookup_weather",
"description": "Find weather",
"parameters": { "type": "object" }
}]
}),
)]),
}]);

assert_eq!(imported.messages.len(), 1);
assert_eq!(
imported.metadata,
Some(json!({
"object": "response",
"id": "resp_123",
"tools": [{
"type": "function",
"function": {
"name": "lookup_weather",
"description": "Find weather",
"parameters": { "type": "object" }
}
}]
}))
);
}
}
5 changes: 4 additions & 1 deletion crates/lingua/src/processing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ pub use adapters::{
insert_opt_string, insert_opt_value, ProviderAdapter,
};
pub use dedup::deduplicate_messages;
pub use import::{import_and_deduplicate_messages, import_messages_from_spans, Span};
pub use import::{
import_and_deduplicate_messages, import_messages_from_spans, import_span_data_from_spans,
ImportedSpanData, Span,
};
pub use transform::{
extract_model, parse_stream_event, response_to_universal, sanitize_payload, transform_request,
transform_response, transform_stream_chunk, ParsedStreamEvent, TransformError, TransformResult,
Expand Down
24 changes: 0 additions & 24 deletions crates/lingua/src/providers/openai/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ use crate::import_parse::{
non_empty_messages, try_convert_non_empty, try_parse, try_parse_vec_or_single,
};
use crate::providers::openai::generated as openai;
use crate::providers::openai::params::OpenAIResponsesExtrasView;
use crate::serde_json;
use crate::universal::convert::TryFromLLM;
use crate::universal::defaults::{EMPTY_OBJECT_STR, REFUSAL_TEXT};
Expand Down Expand Up @@ -302,29 +301,6 @@ fn try_messages_from_openai_instructions(input: openai::Instructions) -> Option<
}
}

fn extract_instructions_from_openai_metadata_value(metadata: &serde_json::Value) -> Option<String> {
let typed = match metadata {
serde_json::Value::String(metadata_json) => {
let parsed = serde_json::from_str::<serde_json::Value>(metadata_json).ok()?;
serde_json::from_value::<OpenAIResponsesExtrasView>(parsed).ok()?
}
_ => serde_json::from_value::<OpenAIResponsesExtrasView>(metadata.clone()).ok()?,
};
typed.instructions
}

pub(crate) fn try_system_message_from_openai_metadata(
metadata: &serde_json::Value,
) -> Option<Message> {
let instructions = extract_instructions_from_openai_metadata_value(metadata)?;
if instructions.is_empty() {
return None;
}
Some(Message::System {
content: UserContent::String(instructions),
})
}

pub(crate) fn try_parse_openai_for_import(data: &serde_json::Value) -> Option<Vec<Message>> {
// Prefer chat-completions request messages before Responses InputItem parsing.
// Chat-completions arrays can deserialize as InputItems, but that path drops
Expand Down
Loading
Loading