use pyo3::prelude::*;
use pyo3::create_exception;
use pyo3::exceptions::PyRuntimeError;
use pyo3::Python;
use pyo3::types::{PyAny, PyDict, PyModule};
use pyo3::Bound;
create_exception!(openai_harmony, HarmonyError, PyRuntimeError);
use crate::{
chat::{Message, Role, ToolNamespaceConfig},
encoding::{HarmonyEncoding, ParseOptions, StreamableParser},
load_harmony_encoding, HarmonyEncodingName,
};
#[pyclass]
struct PyHarmonyEncoding {
inner: HarmonyEncoding,
}
#[pyclass]
struct PyStreamableParser {
inner: StreamableParser,
}
#[pyclass]
pub enum PyStreamState {
ExpectStart,
Header,
Content,
}
#[pymethods]
impl PyHarmonyEncoding {
#[new]
fn new(name: &str) -> PyResult<Self> {
let parsed: HarmonyEncodingName = name
.parse::<HarmonyEncodingName>()
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
let encoding = load_harmony_encoding(parsed)
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))?;
Ok(Self { inner: encoding })
}
#[getter]
fn name(&self) -> &str {
self.inner.name()
}
fn render_conversation_for_completion(
&self,
conversation_json: &str,
next_turn_role: &str,
config: Option<Bound<'_, PyDict>>,
) -> PyResult<Vec<u32>> {
let conversation: crate::chat::Conversation = serde_json::from_str(conversation_json)
.map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"invalid conversation JSON: {e}"
))
})?;
let role = Role::try_from(next_turn_role).map_err(|_| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"unknown role: {next_turn_role}"
))
})?;
let rust_config = if let Some(cfg_dict) = config {
let auto_drop_analysis = cfg_dict
.get_item("auto_drop_analysis")?
.and_then(|v| v.extract().ok())
.unwrap_or(true);
Some(crate::encoding::RenderConversationConfig { auto_drop_analysis })
} else {
None
};
self.inner
.render_conversation_for_completion(&conversation, role, rust_config.as_ref())
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
fn render_conversation(
&self,
conversation_json: &str,
config: Option<Bound<'_, PyDict>>,
) -> PyResult<Vec<u32>> {
let conversation: crate::chat::Conversation = serde_json::from_str(conversation_json)
.map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"invalid conversation JSON: {e}"
))
})?;
let rust_config = if let Some(cfg_dict) = config {
let auto_drop_analysis = cfg_dict
.get_item("auto_drop_analysis")?
.and_then(|v| v.extract().ok())
.unwrap_or(true);
Some(crate::encoding::RenderConversationConfig { auto_drop_analysis })
} else {
None
};
self.inner
.render_conversation(&conversation, rust_config.as_ref())
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
}
fn render_conversation_for_training(
&self,
conversation_json: &str,
config: Option<Bound<'_, PyDict>>,
) -> PyResult<Vec<u32>> {
let conversation: crate::chat::Conversation = serde_json::from_str(conversation_json)
.map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"invalid conversation JSON: {e}"
))
})?;
let rust_config = if let Some(cfg_dict) = config {
let auto_drop_analysis = cfg_dict
.get_item("auto_drop_analysis")?
.and_then(|v| v.extract().ok())
.unwrap_or(true);
Some(crate::encoding::RenderConversationConfig { auto_drop_analysis })
} else {
None
};
self.inner
.render_conversation_for_training(&conversation, rust_config.as_ref())
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
}
fn render(
&self,
message_json: &str,
render_options: Option<Bound<'_, PyDict>>,
) -> PyResult<Vec<u32>> {
let message: crate::chat::Message = serde_json::from_str(message_json).map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("invalid message JSON: {e}"))
})?;
let rust_options = if let Some(options_dict) = render_options {
let conversation_has_function_tools = options_dict
.get_item("conversation_has_function_tools")?
.and_then(|v| v.extract().ok())
.unwrap_or(false);
Some(crate::encoding::RenderOptions {
conversation_has_function_tools,
})
} else {
None
};
self.inner
.render(&message, rust_options.as_ref())
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
}
#[allow(clippy::needless_pass_by_value)]
fn parse_messages_from_completion_tokens(
&self,
tokens: Vec<u32>,
role: Option<&str>,
strict: Option<bool>,
) -> PyResult<String> {
let role_parsed = if let Some(r) = role {
Some(Role::try_from(r).map_err(|_| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("unknown role: {r}"))
})?)
} else {
None
};
let options = ParseOptions {
strict: strict.unwrap_or(true),
};
let messages: Vec<Message> = self
.inner
.parse_messages_from_completion_tokens_with_options(tokens, role_parsed, options)
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))?;
serde_json::to_string(&messages).map_err(|e| {
PyErr::new::<HarmonyError, _>(format!("failed to serialise messages to JSON: {e}"))
})
}
fn decode_utf8(&self, tokens: Vec<u32>) -> PyResult<String> {
self.inner
.tokenizer()
.decode_utf8(tokens)
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
fn decode_bytes(&self, tokens: Vec<u32>) -> PyResult<Vec<u8>> {
self.inner
.tokenizer()
.decode_bytes(tokens)
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
fn encode(&self, text: &str, allowed_special: Option<Bound<'_, PyAny>>) -> PyResult<Vec<u32>> {
let allowed_vec: Vec<String> = match allowed_special {
Some(obj) => obj.extract::<Vec<String>>().map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"invalid allowed_special: {e}"
))
})?,
None => Vec::new(),
};
let allowed_set: std::collections::HashSet<&str> =
allowed_vec.iter().map(|s| s.as_str()).collect();
Ok(self.inner.tokenizer().encode(text, &allowed_set).0)
}
fn special_tokens(&self) -> Vec<String> {
self.inner
.tokenizer()
.special_tokens()
.into_iter()
.map(str::to_string)
.collect()
}
fn is_special_token(&self, token: u32) -> bool {
self.inner.tokenizer().is_special_token(token)
}
fn stop_tokens(&self) -> PyResult<Vec<u32>> {
self.inner
.stop_tokens()
.map(|set| set.into_iter().collect())
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
fn stop_tokens_for_assistant_actions(&self) -> PyResult<Vec<u32>> {
self.inner
.stop_tokens_for_assistant_actions()
.map(|set| set.into_iter().collect())
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
}
#[pymethods]
impl PyStreamableParser {
#[new]
fn new(
encoding: &PyHarmonyEncoding,
role: Option<&str>,
strict: Option<bool>,
) -> PyResult<Self> {
let parsed_role = role
.map(|r| {
Role::try_from(r).map_err(|_| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("unknown role: {r}"))
})
})
.transpose()?;
let options = ParseOptions {
strict: strict.unwrap_or(true),
};
let inner =
StreamableParser::new_with_options(encoding.inner.clone(), parsed_role, options)
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))?;
Ok(Self { inner })
}
fn process(&mut self, token: u32) -> PyResult<()> {
self.inner
.process(token)
.map(|_| ())
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
fn process_eos(&mut self) -> PyResult<()> {
self.inner
.process_eos()
.map(|_| ())
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
#[getter]
fn current_content(&self) -> PyResult<String> {
self.inner
.current_content()
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
#[getter]
fn current_role(&self) -> Option<String> {
self.inner.current_role().map(|r| r.as_str().to_string())
}
#[getter]
fn current_content_type(&self) -> Option<String> {
self.inner.current_content_type()
}
#[getter]
fn last_content_delta(&self) -> PyResult<Option<String>> {
self.inner
.last_content_delta()
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
#[getter]
fn messages(&self) -> PyResult<String> {
serde_json::to_string(self.inner.messages()).map_err(|e| {
PyErr::new::<HarmonyError, _>(format!("failed to serialise messages to JSON: {e}"))
})
}
#[getter]
fn tokens(&self) -> Vec<u32> {
self.inner.tokens().to_vec()
}
#[getter]
fn state(&self) -> PyResult<String> {
self.inner
.state_json()
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
#[getter]
fn current_recipient(&self) -> Option<String> {
self.inner.current_recipient()
}
#[getter]
fn current_channel(&self) -> Option<String> {
self.inner.current_channel()
}
}
#[pymodule]
fn openai_harmony(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHarmonyEncoding>()?;
m.add_class::<PyStreamableParser>()?;
m.add_class::<PyStreamState>()?;
m.add("HarmonyError", _py.get_type::<HarmonyError>())?;
#[pyfunction(name = "load_harmony_encoding")]
fn load_harmony_encoding_py(py: Python<'_>, name: &str) -> PyResult<Py<PyHarmonyEncoding>> {
let enc = PyHarmonyEncoding::new(name)?;
Py::new(py, enc)
}
m.add_function(pyo3::wrap_pyfunction!(load_harmony_encoding_py, m)?)?;
#[pyfunction]
fn get_tool_namespace_config(py: Python<'_>, tool: &str) -> PyResult<PyObject> {
let cfg = match tool {
"browser" => ToolNamespaceConfig::browser(),
"python" => ToolNamespaceConfig::python(),
_ => {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"Unknown tool namespace: {tool}"
)));
}
};
let py_cfg =
serde_json::to_value(&cfg).map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))?;
let json_str = serde_json::to_string(&py_cfg)
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))?;
let json_mod = PyModule::import(py, "json")?;
let py_obj = json_mod.call_method1("loads", (json_str,))?;
Ok(py_obj.into())
}
m.add_function(pyo3::wrap_pyfunction!(get_tool_namespace_config, m)?)?;
Ok(())
}