openai_harmony/
encoding.rs

1use crate::{
2    chat::{Author, Content, Message, ReasoningEffort, Role, SystemContent, TextContent},
3    tiktoken::{CoreBPE, Rank},
4};
5use anyhow::Context as _;
6use std::{
7    collections::{HashMap, HashSet},
8    sync::Arc,
9    vec,
10};
11
12// Parsed representation of a message header.
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct ParsedHeader {
15    author: Author,
16    recipient: Option<String>,
17    channel: Option<String>,
18    content_type: Option<String>,
19}
20
21#[derive(thiserror::Error, Debug)]
22pub(crate) enum RenderFormattingTokenError {
23    #[error("tried to render unmapped formatting token {0}")]
24    UnmappedToken(FormattingToken),
25
26    #[error(
27        "Expected encoding of formatting token {token} to be a single token, but got {encoding:?}"
28    )]
29    InvalidEncoding {
30        token: FormattingToken,
31        encoding: Vec<Rank>,
32    },
33}
34
35/// These are formatting tokens that the renderer can use to generically
36/// format the output of the model, but at formatting time, they are replaced
37/// by actual tokens from the tokenizers vocabulary.
38#[allow(dead_code)]
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40pub(crate) enum FormattingToken {
41    Start,
42    Message,
43    EndMessage,
44    EndMessageDoneSampling,
45    EndMessageAssistantToTool,
46    Refusal,
47    ConstrainedFormat,
48    Channel,
49    BeginUntrusted,
50    EndUntrusted,
51    MetaSep,
52    MetaEnd,
53}
54
55impl FormattingToken {
56    fn as_str(&self) -> &str {
57        match self {
58            FormattingToken::Start => "<|start|>",
59            FormattingToken::Message => "<|message|>",
60            FormattingToken::EndMessage => "<|end|>",
61            FormattingToken::EndMessageDoneSampling => "<|return|>",
62            FormattingToken::EndMessageAssistantToTool => "<|call|>",
63            FormattingToken::Refusal => "<|refusal|>",
64            FormattingToken::ConstrainedFormat => "<|constrain|>",
65            FormattingToken::Channel => "<|channel|>",
66            FormattingToken::BeginUntrusted => "<|untrusted|>",
67            FormattingToken::EndUntrusted => "<|end_untrusted|>",
68            FormattingToken::MetaSep => "<|channel|>",
69            FormattingToken::MetaEnd => "<|meta_end|>",
70        }
71    }
72}
73
74impl std::fmt::Display for FormattingToken {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(f, "{}", self.as_str())
77    }
78}
79
80#[allow(dead_code)]
81#[derive(Clone)]
82pub struct HarmonyEncoding {
83    pub(crate) name: String,
84    pub(crate) n_ctx: usize,
85    pub(crate) max_message_tokens: usize,
86    pub(crate) max_action_length: usize,
87    pub(crate) tokenizer_name: String,
88    pub(crate) tokenizer: Arc<CoreBPE>,
89    pub(crate) format_token_mapping: HashMap<FormattingToken, String>,
90    pub(crate) stop_formatting_tokens: HashSet<FormattingToken>,
91    pub(crate) stop_formatting_tokens_for_assistant_actions: HashSet<FormattingToken>,
92}
93
94impl std::fmt::Debug for HarmonyEncoding {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.debug_struct("HarmonyEncoding")
97            .field("name", &self.name)
98            .field("tokenizer_name", &self.tokenizer_name)
99            .field("n_ctx", &self.n_ctx)
100            .field("max_message_tokens", &self.max_message_tokens)
101            .field("max_action_length", &self.max_action_length)
102            .finish()
103    }
104}
105
106impl std::fmt::Display for HarmonyEncoding {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(f, "Renderer({})", self.name)
109    }
110}
111
112// General methods
113impl HarmonyEncoding {
114    pub fn name(&self) -> &str {
115        &self.name
116    }
117
118    pub fn tokenizer_name(&self) -> &str {
119        &self.tokenizer_name
120    }
121
122    pub fn max_message_tokens(&self) -> usize {
123        self.max_message_tokens
124    }
125
126    pub fn tokenizer(&self) -> &CoreBPE {
127        &self.tokenizer
128    }
129
130    pub fn stop_tokens(&self) -> anyhow::Result<HashSet<Rank>> {
131        self.stop_formatting_tokens
132            .iter()
133            .copied()
134            .map(|t| match self.render_formatting_token(t) {
135                Ok(t) => Ok(t),
136                Err(RenderFormattingTokenError::UnmappedToken(_)) => Err(anyhow::anyhow!(
137                    "token {t} was specified as a stop token, but is not mapped"
138                )),
139                Err(e) => Err(anyhow::anyhow!(e).context("could not render stop token")),
140            })
141            .collect()
142    }
143
144    pub fn stop_tokens_for_assistant_actions(&self) -> anyhow::Result<HashSet<Rank>> {
145        self.stop_formatting_tokens_for_assistant_actions
146            .iter()
147            .copied()
148            .map(|t| match self.render_formatting_token(t) {
149                Ok(t) => Ok(t),
150                Err(RenderFormattingTokenError::UnmappedToken(_)) => Err(anyhow::anyhow!(
151                    "token {t} was specified as a stop token, but is not mapped"
152                )),
153                Err(e) => Err(anyhow::anyhow!(e).context("could not render stop token")),
154            })
155            .collect()
156    }
157}
158
159// Methods for rendering conversations
160impl HarmonyEncoding {
161    /// Renders a conversation into a collection of tokens.
162    pub fn render_conversation_into<'a, I, B>(
163        &self,
164        conversation: I,
165        into: &mut B,
166        config: Option<&RenderConversationConfig>,
167    ) -> anyhow::Result<()>
168    where
169        I: IntoIterator<Item = &'a Message>,
170        B: Extend<Rank>,
171    {
172        let messages: Vec<_> = conversation.into_iter().collect();
173        let has_function_tools = messages.iter().any(|msg| {
174            msg.content.iter().any(|c| {
175                if let Content::DeveloperContent(dev) = c {
176                    if let Some(tools) = &dev.tools {
177                        if let Some(ns) = tools.get("functions") {
178                            !ns.tools.is_empty()
179                        } else {
180                            false
181                        }
182                    } else {
183                        false
184                    }
185                } else {
186                    false
187                }
188            })
189        });
190        let render_options = RenderOptions {
191            conversation_has_function_tools: has_function_tools,
192        };
193        let last_assistant_is_final = messages
194            .iter()
195            .rev()
196            .find_map(|msg| {
197                (msg.author.role == Role::Assistant)
198                    .then(|| msg.channel.as_deref() == Some("final"))
199            })
200            .unwrap_or(false);
201
202        let should_drop_analysis =
203            config.is_some_and(|c| c.auto_drop_analysis && last_assistant_is_final);
204
205        let first_final_idx = messages
206            .iter()
207            .position(|msg| msg.channel.as_deref() == Some("final"));
208
209        let result = messages
210            .iter()
211            .enumerate()
212            .filter(|(idx, msg)| {
213                !(should_drop_analysis
214                    && first_final_idx.is_some_and(|first| *idx < first)
215                    && msg.channel.as_deref() == Some("analysis"))
216            })
217            .try_for_each(|(_, msg)| self.render_into(msg, into, Some(&render_options)));
218        result?;
219        Ok(())
220    }
221
222    /// Renders a conversation into a collection of tokens, adding the next turn role.
223    ///
224    /// This method is used to prepare a conversation for inference.
225    pub fn render_conversation_for_completion_into<'a, I, B>(
226        &self,
227        conversation: I,
228        next_turn_role: Role,
229        into: &mut B,
230        config: Option<&RenderConversationConfig>,
231    ) -> anyhow::Result<()>
232    where
233        I: IntoIterator<Item = &'a Message>,
234        B: Extend<Rank>,
235    {
236        let _config = config.unwrap_or(&RenderConversationConfig::default());
237        self.render_conversation_into(conversation, into, config)?;
238        self.render_formatting_token_into(FormattingToken::Start, into)?;
239        self.render_text_into(next_turn_role.as_str(), into)?;
240        Ok(())
241    }
242
243    pub fn render_conversation_for_completion<'a, I>(
244        &self,
245        conversation: I,
246        next_turn_role: Role,
247        config: Option<&RenderConversationConfig>,
248    ) -> anyhow::Result<Vec<Rank>>
249    where
250        I: IntoIterator<Item = &'a Message>,
251    {
252        let mut into = vec![];
253        self.render_conversation_for_completion_into(
254            conversation,
255            next_turn_role,
256            &mut into,
257            config,
258        )?;
259        Ok(into)
260    }
261
262    /// Render a conversation for training.
263    ///
264    /// If the last message in the conversation is an assistant message to the
265    /// `final` channel, replace the trailing `<|end|>` token with `<|return|>`.
266    pub fn render_conversation_for_training<'a, I>(
267        &self,
268        conversation: I,
269        config: Option<&RenderConversationConfig>,
270    ) -> anyhow::Result<Vec<Rank>>
271    where
272        I: IntoIterator<Item = &'a Message>,
273    {
274        let messages: Vec<&Message> = conversation.into_iter().collect();
275        let mut out = vec![];
276        self.render_conversation_into(messages.iter().copied(), &mut out, config)?;
277        if let Some(last) = messages.last() {
278            if last.author.role == Role::Assistant && last.channel.as_deref() == Some("final") {
279                if let Some(last_token) = out.last_mut() {
280                    *last_token =
281                        self.render_formatting_token(FormattingToken::EndMessageDoneSampling)?;
282                }
283            }
284        }
285        Ok(out)
286    }
287
288    /// Render a conversation without appending a new role.
289    pub fn render_conversation<'a, I>(
290        &self,
291        conversation: I,
292        config: Option<&RenderConversationConfig>,
293    ) -> anyhow::Result<Vec<Rank>>
294    where
295        I: IntoIterator<Item = &'a Message>,
296    {
297        let mut out = vec![];
298        self.render_conversation_into(conversation, &mut out, config)?;
299        Ok(out)
300    }
301
302    /// Render a single message into tokens.
303    pub fn render(
304        &self,
305        message: &Message,
306        render_options: Option<&RenderOptions>,
307    ) -> anyhow::Result<Vec<Rank>> {
308        let mut out = vec![];
309        Render::<Message>::render(self, message, &mut out, render_options)?;
310        Ok(out)
311    }
312
313    /// Render a single message into the provided buffer.
314    pub fn render_into<B>(
315        &self,
316        message: &Message,
317        into: &mut B,
318        render_options: Option<&RenderOptions>,
319    ) -> anyhow::Result<()>
320    where
321        B: Extend<Rank>,
322    {
323        Render::<Message>::render(self, message, into, render_options)
324    }
325}
326
327// Rendering helper methods
328impl HarmonyEncoding {
329    fn mapped_format_token(&self, t: FormattingToken) -> Option<&str> {
330        self.format_token_mapping.get(&t).map(|s| s.as_str())
331    }
332
333    fn render_formatting_token(
334        &self,
335        t: FormattingToken,
336    ) -> Result<Rank, RenderFormattingTokenError> {
337        let mapped = self
338            .mapped_format_token(t)
339            .ok_or(RenderFormattingTokenError::UnmappedToken(t))?;
340        let encoded = self.tokenizer.encode_with_special_tokens(mapped);
341        if encoded.len() != 1 {
342            return Err(RenderFormattingTokenError::InvalidEncoding {
343                token: t,
344                encoding: encoded,
345            });
346        }
347        Ok(encoded[0])
348    }
349
350    fn render_formatting_token_into<B>(
351        &self,
352        t: FormattingToken,
353        into: &mut B,
354    ) -> anyhow::Result<()>
355    where
356        B: Extend<Rank>,
357    {
358        let r = self.render_formatting_token(t)?;
359        into.extend(std::iter::once(r));
360        Ok(())
361    }
362
363    fn render_text_into<T, B>(&self, text: T, into: &mut B) -> anyhow::Result<()>
364    where
365        T: AsRef<str>,
366        B: Extend<Rank>,
367    {
368        into.extend(self.tokenizer.encode_ordinary(text.as_ref()));
369        Ok(())
370    }
371
372    pub fn parse_messages_from_completion_tokens<I>(
373        &self,
374        tokens: I,
375        role: Option<Role>,
376    ) -> anyhow::Result<Vec<Message>>
377    where
378        I: IntoIterator<Item = Rank>,
379    {
380        let mut parser = StreamableParser::new(self.clone(), role)?;
381        for token in tokens {
382            parser.process(token)?;
383        }
384        parser.process_eos()?;
385        Ok(parser.into_messages())
386    }
387
388    /// Helper to convert a JSON schema (OpenAPI style) to a TypeScript type definition.
389    fn json_schema_to_typescript(schema: &serde_json::Value, indent: &str) -> String {
390        // Helper to check if this schema is an enum
391        fn is_enum(schema: &serde_json::Value) -> bool {
392            schema
393                .get("enum")
394                .and_then(|e| e.as_array())
395                .is_some_and(|arr| !arr.is_empty())
396        }
397
398        // Handle oneOf at the top level
399        if let Some(one_of) = schema.get("oneOf") {
400            if let Some(arr) = one_of.as_array() {
401                let mut out = String::new();
402                let mut first = true;
403                for variant in arr {
404                    if !first {
405                        out.push('\n');
406                        out.push_str(&format!("{indent} | "));
407                    } else {
408                        out.push_str(&format!("\n{indent} | "));
409                        first = false;
410                    }
411                    let type_str =
412                        Self::json_schema_to_typescript(variant, &format!("{indent}   "));
413                    let mut type_str = type_str;
414                    if variant
415                        .get("nullable")
416                        .and_then(|n| n.as_bool())
417                        .unwrap_or(false)
418                        && !type_str.contains("null")
419                    {
420                        type_str = format!("{type_str} | null");
421                    }
422                    out.push_str(&type_str);
423                    // Add trailing comments (description, default)
424                    let mut trailing_comments = Vec::new();
425                    if let Some(desc) = variant.get("description") {
426                        if let Some(desc_str) = desc.as_str() {
427                            trailing_comments.push(desc_str.to_string());
428                        }
429                    }
430                    if let Some(default) = variant.get("default") {
431                        if default.is_string() && !is_enum(variant) {
432                            trailing_comments
433                                .push(format!("default: \"{}\"", default.as_str().unwrap()));
434                        } else {
435                            trailing_comments.push(format!("default: {default}"));
436                        }
437                    }
438                    if !trailing_comments.is_empty() {
439                        out.push_str(&format!(" // {}", trailing_comments.join(" ")));
440                    }
441                }
442                return out;
443            }
444        }
445        // Handle type as array (e.g., ["number", "string"])
446        if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
447            let mut type_strings = Vec::new();
448            for ty in types {
449                if let Some(ty_str) = ty.as_str() {
450                    let mapped = match ty_str {
451                        "integer" => "number",
452                        other => other,
453                    };
454                    type_strings.push(mapped.to_string());
455                }
456            }
457            if !type_strings.is_empty() {
458                return type_strings.join(" | ");
459            }
460        }
461        // Handle type
462        if let Some(ty) = schema.get("type").and_then(|v| v.as_str()) {
463            match ty {
464                "object" => {
465                    let mut out = String::new();
466                    // Render object-level description as comment
467                    if let Some(desc) = schema.get("description") {
468                        if let Some(desc_str) = desc.as_str() {
469                            out.push_str(&format!("{indent}// {desc_str}\n"));
470                        }
471                    }
472                    out.push_str("{\n");
473
474                    if let Some(props) = schema.get("properties") {
475                        if let Some(props_map) = props.as_object() {
476                            // Determine required fields
477                            let mut required = std::collections::HashSet::new();
478                            if let Some(req) = schema.get("required") {
479                                if let Some(req_arr) = req.as_array() {
480                                    for r in req_arr {
481                                        if let Some(s) = r.as_str() {
482                                            required.insert(s);
483                                        }
484                                    }
485                                }
486                            }
487                            for (key, val) in props_map {
488                                // Render title, description, and examples as comments
489                                if let Some(title) = val.get("title") {
490                                    if let Some(title_str) = title.as_str() {
491                                        out.push_str(&format!(
492                                            "{indent}// {title_str}\n{indent}//\n"
493                                        ));
494                                    }
495                                }
496                                // Only render description here if not a oneOf property
497                                if val.get("oneOf").is_none() {
498                                    if let Some(desc) = val.get("description") {
499                                        if let Some(desc_str) = desc.as_str() {
500                                            out.push_str(&format!("{indent}// {desc_str}\n"));
501                                        }
502                                    }
503                                }
504                                if let Some(examples) = val.get("examples") {
505                                    if let Some(arr) = examples.as_array() {
506                                        if !arr.is_empty() {
507                                            out.push_str(&format!("{indent}// Examples:\n"));
508                                            for ex in arr {
509                                                if let Some(ex_str) = ex.as_str() {
510                                                    out.push_str(&format!(
511                                                        "{indent}// - \"{ex_str}\"\n"
512                                                    ));
513                                                }
514                                            }
515                                        }
516                                    }
517                                }
518                                // Handle oneOf at the property level
519                                if let Some(one_of) = val.get("oneOf") {
520                                    if let Some(arr) = one_of.as_array() {
521                                        // Deduplicate property-level description if it matches the first variant's description
522                                        let mut property_desc: Option<&str> = None;
523                                        if let Some(desc) = val.get("description") {
524                                            if let Some(desc_str) = desc.as_str() {
525                                                property_desc = Some(desc_str);
526                                            }
527                                        }
528                                        let mut skip_property_desc = false;
529                                        if let Some(desc_str) = property_desc {
530                                            if let Some(first_variant) = arr.first() {
531                                                if let Some(variant_desc) =
532                                                    first_variant.get("description")
533                                                {
534                                                    if let Some(variant_desc_str) =
535                                                        variant_desc.as_str()
536                                                    {
537                                                        if desc_str == variant_desc_str {
538                                                            skip_property_desc = true;
539                                                        }
540                                                    }
541                                                }
542                                            }
543                                        }
544                                        // Add property-level comments above the property name if not skipped
545                                        let mut rendered_property_desc_above = false;
546                                        if !skip_property_desc {
547                                            if let Some(desc_str) = property_desc {
548                                                out.push_str(&format!("{indent}// {desc_str}\n"));
549                                                rendered_property_desc_above = true;
550                                            }
551                                        }
552                                        if let Some(default) = val.get("default") {
553                                            if default.is_string() && !is_enum(val) {
554                                                out.push_str(&format!(
555                                                    "{}// default: \"{}\"\n",
556                                                    indent,
557                                                    default.as_str().unwrap()
558                                                ));
559                                            } else if default.is_string() {
560                                                out.push_str(&format!(
561                                                    "{}// default: {}\n",
562                                                    indent,
563                                                    default.as_str().unwrap()
564                                                ));
565                                            } else {
566                                                out.push_str(&format!(
567                                                    "{indent}// default: {default}\n"
568                                                ));
569                                            }
570                                        }
571                                        // Add property name and optional marker
572                                        out.push_str(&format!(
573                                            "{}{}{}:\n",
574                                            indent,
575                                            key,
576                                            if required.contains(key.as_str()) {
577                                                ""
578                                            } else {
579                                                "?"
580                                            }
581                                        ));
582                                        // Render each variant
583                                        for (i, variant) in arr.iter().enumerate() {
584                                            out.push_str(&format!("{indent} | "));
585                                            let type_str = Self::json_schema_to_typescript(
586                                                variant,
587                                                &format!("{indent}   "),
588                                            );
589                                            // Handle nullable in variant
590                                            let mut type_str = type_str;
591                                            if variant
592                                                .get("nullable")
593                                                .and_then(|n| n.as_bool())
594                                                .unwrap_or(false)
595                                                && !type_str.contains("null")
596                                            {
597                                                type_str = format!("{type_str} | null");
598                                            }
599                                            out.push_str(&type_str);
600                                            // Add variant-level comments after the type
601                                            let mut trailing_comments = Vec::new();
602                                            if i == 0 && rendered_property_desc_above {
603                                                // Do not add any description for the first variant if property-level description was rendered above
604                                            } else if let Some(desc) = variant.get("description") {
605                                                if let Some(desc_str) = desc.as_str() {
606                                                    // Only render if not equal to property-level description
607                                                    if Some(desc_str) != property_desc {
608                                                        trailing_comments
609                                                            .push(desc_str.to_string());
610                                                    }
611                                                }
612                                            }
613                                            if let Some(default) = variant.get("default") {
614                                                if default.is_string() && !is_enum(variant) {
615                                                    trailing_comments.push(format!(
616                                                        "default: \"{}\"",
617                                                        default.as_str().unwrap()
618                                                    ));
619                                                } else if default.is_string() {
620                                                    trailing_comments.push(format!(
621                                                        "default: {}",
622                                                        default.as_str().unwrap()
623                                                    ));
624                                                } else {
625                                                    trailing_comments
626                                                        .push(format!("default: {default}"));
627                                                }
628                                            }
629                                            if !trailing_comments.is_empty() {
630                                                out.push_str(&format!(
631                                                    " // {}",
632                                                    trailing_comments.join(" ")
633                                                ));
634                                            }
635                                            out.push('\n');
636                                        }
637                                        out.push_str(&format!("{indent},\n"));
638                                        continue;
639                                    }
640                                }
641                                // Normal property rendering
642                                out.push_str(&format!(
643                                    "{}{}{}: ",
644                                    indent,
645                                    key,
646                                    if required.contains(key.as_str()) {
647                                        ""
648                                    } else {
649                                        "?"
650                                    }
651                                ));
652                                // Handle nullable
653                                let mut type_str =
654                                    Self::json_schema_to_typescript(val, &format!("{indent}    "));
655                                if val
656                                    .get("nullable")
657                                    .and_then(|n| n.as_bool())
658                                    .unwrap_or(false)
659                                    && !type_str.contains("null")
660                                {
661                                    type_str = format!("{type_str} | null");
662                                }
663                                out.push_str(&type_str);
664                                out.push(',');
665                                // Add default as comment if present (and not already handled)
666                                if val.get("oneOf").is_none() {
667                                    if let Some(default) = val.get("default") {
668                                        if default.is_string() && !is_enum(val) {
669                                            out.push_str(&format!(
670                                                " // default: \"{}\"",
671                                                default.as_str().unwrap()
672                                            ));
673                                        } else if default.is_string() {
674                                            out.push_str(&format!(
675                                                " // default: {}",
676                                                default.as_str().unwrap()
677                                            ));
678                                        } else {
679                                            out.push_str(&format!(" // default: {default}"));
680                                        }
681                                    }
682                                }
683                                out.push('\n');
684                            }
685                        }
686                    }
687                    out.push_str(&format!("{indent}}}"));
688                    out
689                }
690                "string" => {
691                    if let Some(enum_vals) = schema.get("enum") {
692                        if let Some(arr) = enum_vals.as_array() {
693                            let enums: Vec<String> = arr
694                                .iter()
695                                .filter_map(|v| v.as_str().map(|s| format!("\"{s}\"")))
696                                .collect();
697                            if !enums.is_empty() {
698                                return enums.join(" | ");
699                            }
700                        }
701                    }
702                    "string".to_string()
703                }
704                "number" => "number".to_string(),
705                "integer" => "number".to_string(),
706                "boolean" => "boolean".to_string(),
707                "array" => {
708                    if let Some(items) = schema.get("items") {
709                        format!("{}[]", Self::json_schema_to_typescript(items, indent))
710                    } else {
711                        "Array<any>".to_string()
712                    }
713                }
714                _ => "any".to_string(),
715            }
716        } else if let Some(one_of) = schema.get("oneOf") {
717            // Defensive: already handled above, but just in case
718            if let Some(arr) = one_of.as_array() {
719                let mut out = String::new();
720                let mut first = true;
721                for variant in arr {
722                    if !first {
723                        out.push_str("\n | ");
724                    } else {
725                        first = false;
726                    }
727                    out.push_str(&Self::json_schema_to_typescript(variant, indent));
728                }
729                return out;
730            }
731            "any".to_string()
732        } else {
733            "any".to_string()
734        }
735    }
736
737    /// Helper to template the tools section for system content rendering.
738    fn template_tools_section(
739        tools: &std::collections::BTreeMap<String, crate::chat::ToolNamespaceConfig>,
740    ) -> String {
741        let mut tool_sections = Vec::<String>::new();
742        tool_sections.push("# Tools".to_string());
743        for ns_config in tools.values() {
744            let mut tool_section_content = Vec::<String>::new();
745            tool_section_content.push(format!("## {}\n", ns_config.name));
746            if let Some(desc) = &ns_config.description {
747                for line in desc.lines() {
748                    if !ns_config.tools.is_empty() {
749                        tool_section_content.push(format!("// {line}"));
750                    } else {
751                        tool_section_content.push(line.to_string());
752                    }
753                }
754            }
755            if !ns_config.tools.is_empty() {
756                tool_section_content.push(format!("namespace {} {{\n", ns_config.name));
757                for tool in &ns_config.tools {
758                    for line in tool.description.lines() {
759                        tool_section_content.push(format!("// {line}"));
760                    }
761                    if let Some(params) = &tool.parameters {
762                        let param_type = Self::json_schema_to_typescript(params, "");
763                        tool_section_content.push(format!(
764                            "type {} = (_: {}) => any;\n",
765                            tool.name, param_type
766                        ));
767                    } else {
768                        tool_section_content.push(format!("type {} = () => any;\n", tool.name));
769                    }
770                }
771                tool_section_content.push(format!("}} // namespace {}", ns_config.name));
772            }
773            tool_sections.push(tool_section_content.join("\n"));
774        }
775        tool_sections.join("\n\n")
776    }
777}
778
779#[derive(Clone, Copy, Debug, Default)]
780pub struct RenderOptions {
781    pub conversation_has_function_tools: bool,
782}
783
784trait Render<T: ?Sized> {
785    fn render<B>(
786        &self,
787        item: &T,
788        into: &mut B,
789        render_options: Option<&RenderOptions>,
790    ) -> anyhow::Result<()>
791    where
792        B: Extend<Rank>;
793}
794
795impl Render<Message> for HarmonyEncoding {
796    fn render<B>(
797        &self,
798        message: &Message,
799        into: &mut B,
800        render_options: Option<&RenderOptions>,
801    ) -> anyhow::Result<()>
802    where
803        B: Extend<Rank>,
804    {
805        self.render_formatting_token_into(FormattingToken::Start, into)?;
806
807        // render role then username
808        if matches!(message.author.role, Role::Tool) {
809            // for tools we only put the name
810            if let Some(name) = &message.author.name {
811                self.render_text_into(name, into)?;
812            } else {
813                anyhow::bail!("Tools should have a name!");
814            }
815        } else {
816            // For users and assistants we put both the role, and optionally the user name.
817            self.render_text_into(message.author.role.as_str(), into)?;
818            if let Some(name) = &message.author.name {
819                self.render_text_into(format!(":{name}"), into)?;
820            }
821        };
822
823        // next render the header recipient, if there is one
824        if let Some(recipient) = &message.recipient {
825            if recipient != "all" {
826                self.render_text_into(format!(" to={recipient}"), into)?;
827            }
828        }
829
830        // next header channel
831        if let Some(channel) = &message.channel {
832            self.render_formatting_token_into(FormattingToken::Channel, into)?;
833            self.render_text_into(channel, into)?;
834        }
835
836        // finally content type
837        if let Some(content_type) = &message.content_type {
838            self.render_text_into(format!(" {content_type}"), into)?;
839        }
840
841        self.render_formatting_token_into(FormattingToken::Message, into)?;
842        for content in message.content.iter() {
843            // SystemContent is only allowed in system messages
844            if let crate::chat::Content::SystemContent(_) = content {
845                anyhow::ensure!(
846                    message.author.role == crate::chat::Role::System,
847                    "SystemContent may only appear in system messages, found in {:?}",
848                    message.author.role
849                );
850            }
851            if let crate::chat::Content::DeveloperContent(_) = content {
852                anyhow::ensure!(
853                    message.author.role == crate::chat::Role::Developer,
854                    "DeveloperContent may only appear in developer messages, found in {:?}",
855                    message.author.role
856                );
857            }
858            Render::<Content>::render(self, content, into, render_options)?;
859        }
860
861        // If there is a tool call we should render a tool call token
862        if message.author.role == crate::chat::Role::Assistant && message.recipient.is_some() {
863            self.render_formatting_token_into(FormattingToken::EndMessageAssistantToTool, into)?;
864        } else {
865            self.render_formatting_token_into(FormattingToken::EndMessage, into)?;
866        }
867        Ok(())
868    }
869}
870
871// Dispatch Content variants to their specific Render implementations
872impl Render<Content> for HarmonyEncoding {
873    fn render<B>(
874        &self,
875        content: &Content,
876        into: &mut B,
877        render_options: Option<&RenderOptions>,
878    ) -> anyhow::Result<()>
879    where
880        B: Extend<Rank>,
881    {
882        match content {
883            Content::Text(text) => Render::<TextContent>::render(self, text, into, render_options),
884            Content::SystemContent(sys) => {
885                Render::<SystemContent>::render(self, sys, into, render_options)
886            }
887            Content::DeveloperContent(dev) => {
888                Render::<crate::chat::DeveloperContent>::render(self, dev, into, render_options)
889            }
890        }
891    }
892}
893
894// Render plain text content
895impl Render<TextContent> for HarmonyEncoding {
896    fn render<B>(
897        &self,
898        text: &TextContent,
899        into: &mut B,
900        _render_options: Option<&RenderOptions>,
901    ) -> anyhow::Result<()>
902    where
903        B: Extend<Rank>,
904    {
905        self.render_text_into(&text.text, into)
906    }
907}
908
909// Render system-specific content (model identity, instructions, effort)
910impl Render<SystemContent> for HarmonyEncoding {
911    fn render<B>(
912        &self,
913        sys: &SystemContent,
914        into: &mut B,
915        render_options: Option<&RenderOptions>,
916    ) -> anyhow::Result<()>
917    where
918        B: Extend<Rank>,
919    {
920        let mut sections = Vec::<String>::new();
921
922        let mut top_section = Vec::<String>::new();
923        if let Some(model_id) = &sys.model_identity {
924            top_section.push(model_id.clone());
925        }
926        if let Some(knowledge_cutoff) = &sys.knowledge_cutoff {
927            top_section.push(format!("Knowledge cutoff: {knowledge_cutoff}"));
928        }
929        if let Some(conversation_start_date) = &sys.conversation_start_date {
930            top_section.push(format!("Current date: {conversation_start_date}"));
931        }
932        if !top_section.is_empty() {
933            sections.push(top_section.join("\n"));
934        }
935
936        let mut instructions_and_reasoning = Vec::<String>::new();
937        if let Some(effort) = sys.reasoning_effort {
938            let effort_str = match effort {
939                ReasoningEffort::Low => "low",
940                ReasoningEffort::Medium => "medium",
941                ReasoningEffort::High => "high",
942            };
943            instructions_and_reasoning.push(format!("Reasoning: {effort_str}"));
944        }
945        if !instructions_and_reasoning.is_empty() {
946            sections.push(instructions_and_reasoning.join("\n"));
947        }
948
949        if let Some(tools) = &sys.tools {
950            if !tools.is_empty() {
951                sections.push(Self::template_tools_section(tools));
952            }
953        }
954
955        if let Some(channel_config) = &sys.channel_config {
956            if !channel_config.valid_channels.is_empty() {
957                let channels_str = channel_config.valid_channels.join(", ");
958                let mut channels_header = format!("# Valid channels: {channels_str}.");
959                if channel_config.channel_required {
960                    channels_header.push_str(" Channel must be included for every message.");
961                }
962                if render_options.is_some_and(|o| o.conversation_has_function_tools) {
963                    channels_header.push('\n');
964                    channels_header.push_str(
965                        "Calls to these tools must go to the commentary channel: 'functions'.",
966                    );
967                }
968                sections.push(channels_header);
969            }
970        }
971        let formatted = sections.join("\n\n");
972        self.render_text_into(&formatted, into)?;
973        Ok(())
974    }
975}
976
977// Render developer-specific content (instructions, tools)
978impl Render<crate::chat::DeveloperContent> for HarmonyEncoding {
979    fn render<B>(
980        &self,
981        dev: &crate::chat::DeveloperContent,
982        into: &mut B,
983        _render_options: Option<&RenderOptions>,
984    ) -> anyhow::Result<()>
985    where
986        B: Extend<Rank>,
987    {
988        let mut sections = Vec::<String>::new();
989
990        if let Some(instr) = &dev.instructions {
991            sections.push("# Instructions".to_string());
992            sections.push(instr.clone());
993        }
994
995        if let Some(tools) = &dev.tools {
996            if !tools.is_empty() {
997                sections.push(Self::template_tools_section(tools));
998            }
999        }
1000        let formatted = sections.join("\n\n");
1001        self.render_text_into(&formatted, into)?;
1002        Ok(())
1003    }
1004}
1005
1006/// Incremental parser that can consume tokens one by one.
1007///
1008/// It keeps track of all tokens seen so far, exposes all fully parsed messages
1009/// and retains the partially parsed state of the current message.
1010pub struct StreamableParser {
1011    encoding: HarmonyEncoding,
1012    next_role: Option<Role>,
1013    tokens: Vec<Rank>,
1014    messages: Vec<Message>,
1015    state: StreamState,
1016    stop_tokens: HashSet<Rank>,
1017    last_content_delta: Option<String>,
1018    undecoded_tokens: Vec<Rank>,
1019}
1020
1021#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1022pub enum StreamState {
1023    ExpectStart,
1024    Header {
1025        header_tokens: Vec<Rank>,
1026    },
1027    Content {
1028        header: ParsedHeader,
1029        content_tokens: Vec<Rank>,
1030    },
1031}
1032
1033impl StreamableParser {
1034    /// Create a new streaming parser starting with the given role.
1035    pub fn new(encoding: HarmonyEncoding, role: Option<Role>) -> anyhow::Result<Self> {
1036        let stop_tokens = encoding.stop_tokens()?;
1037        let (state, next_role) = match role {
1038            Some(role) => (
1039                StreamState::Header {
1040                    header_tokens: Vec::new(),
1041                },
1042                Some(role),
1043            ),
1044            None => (StreamState::ExpectStart, None),
1045        };
1046        Ok(Self {
1047            encoding,
1048            next_role,
1049            tokens: Vec::new(),
1050            messages: Vec::new(),
1051            state,
1052            stop_tokens,
1053            last_content_delta: None,
1054            undecoded_tokens: Vec::new(),
1055        })
1056    }
1057
1058    /// Consume a single token and update the internal state.
1059    /// Consume a single token and update the internal state.
1060    fn process_next(&mut self, token: Option<Rank>) -> anyhow::Result<&mut Self> {
1061        if let Some(token) = token {
1062            self.tokens.push(token);
1063        }
1064        // Clone next_role up front to avoid borrow checker issues
1065        let next_role_clone = self.next_role.clone();
1066        match &mut self.state {
1067            StreamState::ExpectStart => {
1068                let start = self
1069                    .encoding
1070                    .render_formatting_token(FormattingToken::Start)?;
1071                match token {
1072                    Some(token) if token == start => {
1073                        self.state = StreamState::Header {
1074                            header_tokens: Vec::new(),
1075                        };
1076                    }
1077                    Some(token) => {
1078                        anyhow::bail!(
1079                            "Unexpected token {} while expecting start token {}",
1080                            token,
1081                            start
1082                        );
1083                    }
1084                    None => {
1085                        // receiving EOS while waiting for start token is actually fine
1086                        // as we may have just parsed a stop token. in this case we can
1087                        // simple keep state as is
1088                    }
1089                }
1090            }
1091            StreamState::Header { header_tokens } => {
1092                let msg_tok = self
1093                    .encoding
1094                    .render_formatting_token(FormattingToken::Message)?;
1095                match token {
1096                    Some(token) if token == msg_tok => {
1097                        // Clone the tokens and next_role, then clear the state before parsing
1098                        let header_tokens_cloned = header_tokens.clone();
1099                        let next_role_cloned = next_role_clone;
1100                        // Set state to dummy to drop mutable borrow
1101                        self.state = StreamState::ExpectStart;
1102                        let header =
1103                            self.parse_header_from_tokens(&header_tokens_cloned, next_role_cloned)?;
1104                        self.next_role = None;
1105                        self.state = StreamState::Content {
1106                            header,
1107                            content_tokens: Vec::new(),
1108                        };
1109                    }
1110                    Some(token) => {
1111                        header_tokens.push(token);
1112                    }
1113                    None => {
1114                        anyhow::bail!(
1115                            "Unexpected EOS while waiting for message header to complete"
1116                        );
1117                    }
1118                }
1119            }
1120            StreamState::Content {
1121                header,
1122                content_tokens,
1123            } => {
1124                let is_eos = if let Some(token) = token {
1125                    if self.stop_tokens.contains(&token) {
1126                        // this is a stop token, dont parse and mark EOS
1127                        true
1128                    } else {
1129                        self.undecoded_tokens.push(token);
1130                        // some tokens might not appropriately decode on their own. If they don't
1131                        // we will collect them until they eventually decode
1132                        match self
1133                            .encoding
1134                            .tokenizer()
1135                            .decode_utf8(&self.undecoded_tokens)
1136                        {
1137                            Ok(decoded) => {
1138                                content_tokens.extend(self.undecoded_tokens.iter().copied());
1139                                self.last_content_delta = Some(decoded);
1140                                self.undecoded_tokens.clear();
1141                            }
1142                            Err(_) => {
1143                                self.last_content_delta = None;
1144                            }
1145                        }
1146                        // this was not an EOS
1147                        false
1148                    }
1149                } else {
1150                    // token = None signals EOS to this function
1151                    true
1152                };
1153                if is_eos {
1154                    let text = self.encoding.tokenizer().decode_utf8(content_tokens)?;
1155                    let message = Message {
1156                        author: header.author.clone(),
1157                        recipient: header.recipient.clone(),
1158                        channel: header.channel.clone(),
1159                        content_type: header.content_type.clone(),
1160                        content: vec![Content::Text(TextContent { text })],
1161                    };
1162                    self.messages.push(message);
1163                    self.state = StreamState::ExpectStart;
1164                    self.last_content_delta = None;
1165                    self.undecoded_tokens.clear();
1166                }
1167            }
1168        }
1169        Ok(self)
1170    }
1171
1172    pub fn process(&mut self, token: Rank) -> anyhow::Result<&mut Self> {
1173        self.process_next(Some(token))
1174    }
1175
1176    pub fn process_eos(&mut self) -> anyhow::Result<&mut Self> {
1177        self.process_next(None)?;
1178        Ok(self)
1179    }
1180
1181    fn parse_header_from_tokens(
1182        &self,
1183        header_tokens: &[Rank],
1184        role: Option<Role>,
1185    ) -> anyhow::Result<ParsedHeader> {
1186        let mut header_string = self
1187            .encoding
1188            .tokenizer()
1189            .decode_utf8(header_tokens)
1190            .context("could not decode header")?;
1191
1192        let mut channel: Option<String> = None;
1193        if let Some(channel_marker) = self.encoding.mapped_format_token(FormattingToken::Channel) {
1194            if let Some(idx) = header_string.find(channel_marker) {
1195                let after_marker = &header_string[idx + channel_marker.len()..];
1196                let channel_end = after_marker
1197                    .find(|c: char| c.is_whitespace() || c == '<')
1198                    .unwrap_or(after_marker.len());
1199                let channel_value = &after_marker[..channel_end];
1200                if channel_value.is_empty() {
1201                    anyhow::bail!("channel marker present but no channel value found in header");
1202                }
1203                channel = Some(channel_value.to_string());
1204
1205                let mut new_header = String::new();
1206                new_header.push_str(&header_string[..idx]);
1207                new_header.push_str(&after_marker[channel_end..]);
1208                header_string = new_header;
1209            }
1210        }
1211
1212        // Trim extraneous whitespace that may have been introduced when we
1213        // removed the channel section.
1214        header_string = header_string.trim().to_string();
1215
1216        // If the constrained format marker is present but not preceded by
1217        // whitespace (e.g. "to=foo<|constrain|>json"), insert a space before
1218        // the marker so that splitting on whitespace treats the content type
1219        // as a separate token.
1220        if let Some(constrain_marker) = self
1221            .encoding
1222            .mapped_format_token(FormattingToken::ConstrainedFormat)
1223        {
1224            if header_string.contains(constrain_marker) {
1225                header_string = header_string
1226                    .replace(constrain_marker, &format!(" {constrain_marker}"))
1227                    .trim()
1228                    .to_string();
1229            }
1230        }
1231
1232        let mut parts: Vec<&str> = header_string.split_ascii_whitespace().collect();
1233
1234        let mut role_str_opt: Option<String> = None;
1235        let role = match role {
1236            Some(r) => r,
1237            None => {
1238                let role_str = parts
1239                    .first()
1240                    .context("message header did not contain a role")?;
1241                role_str_opt = Some((*role_str).to_string());
1242                let parsed_role = Role::try_from(*role_str);
1243                let out = match parsed_role {
1244                    Ok(r) => r,
1245                    Err(_) => {
1246                        // If recipient is present, treat as tool call
1247                        if parts.len() > 1 || (parts.len() == 1 && parts[0].starts_with("to=")) {
1248                            parts.remove(0); // Remove the unknown role string
1249                            Role::Tool
1250                        } else {
1251                            return Err(anyhow::anyhow!("Unknown role: {}", role_str));
1252                        }
1253                    }
1254                };
1255                out
1256            }
1257        };
1258
1259        if let Some(&first) = parts.first() {
1260            if first == role.as_str() {
1261                parts.remove(0);
1262            }
1263        }
1264
1265        let mut recipient: Option<String> = None;
1266        let mut content_type: Option<String> = None;
1267
1268        if !parts.is_empty() {
1269            // Determine whether the last token is a content-type or part of the
1270            // recipient specification.
1271            let num_parts = parts.len();
1272            // SAFETY: we know that there is at least one part remaining, because of is_empty check above
1273            let last_part = parts.pop().unwrap();
1274
1275            if let Some(stripped) = last_part.strip_prefix("to=") {
1276                // The header contains a recipient but *no* content-type.
1277                recipient = Some(stripped.to_string());
1278            } else if num_parts == 1 {
1279                // Only one part total (after potential role removal) and it doesn't start
1280                // with "to=" => interpret it as a standalone recipient.
1281                recipient = Some(last_part.to_string());
1282            } else {
1283                // More than one token and the last one is not a recipient -> treat as content-type.
1284                content_type = Some(last_part.to_string());
1285
1286                // After removing the content-type there may be exactly one token describing the recipient.
1287                if let Some(raw_recipient) = parts.pop() {
1288                    recipient = if let Some(stripped) = raw_recipient.strip_prefix("to=") {
1289                        Some(stripped.to_string())
1290                    } else {
1291                        Some(raw_recipient.to_string())
1292                    };
1293                }
1294            }
1295        }
1296        anyhow::ensure!(
1297            parts.is_empty(),
1298            "unexpected tokens remaining in message header: {:?}",
1299            parts
1300        );
1301
1302        let author = if role == Role::Tool {
1303            let name = role_str_opt;
1304            Author { role, name }
1305        } else {
1306            Author { role, name: None }
1307        };
1308        Ok(ParsedHeader {
1309            author,
1310            recipient,
1311            channel,
1312            content_type,
1313        })
1314    }
1315
1316    /// Return the textual content of the current message so far.
1317    pub fn current_content(&self) -> anyhow::Result<String> {
1318        match &self.state {
1319            StreamState::Content { content_tokens, .. } => self
1320                .encoding
1321                .tokenizer()
1322                .decode_utf8(content_tokens)
1323                .map_err(|e| anyhow::anyhow!(e)),
1324            _ => Ok(String::new()),
1325        }
1326    }
1327
1328    /// Role of the current message if it has been parsed.
1329    pub fn current_role(&self) -> Option<Role> {
1330        match &self.state {
1331            StreamState::Content { header, .. } => Some(header.author.role.clone()),
1332            _ => self.next_role.clone(),
1333        }
1334    }
1335
1336    /// Current content type if known.
1337    pub fn current_content_type(&self) -> Option<String> {
1338        match &self.state {
1339            StreamState::Content { header, .. } => header.content_type.clone(),
1340            _ => None,
1341        }
1342    }
1343
1344    /// Decode the last content delta if available.
1345    pub fn last_content_delta(&self) -> anyhow::Result<Option<String>> {
1346        Ok(self.last_content_delta.clone())
1347    }
1348
1349    /// Consume the parser and return all parsed messages.
1350    pub fn into_messages(self) -> Vec<Message> {
1351        self.messages
1352    }
1353
1354    /// All fully parsed messages so far.
1355    pub fn messages(&self) -> &[Message] {
1356        &self.messages
1357    }
1358
1359    /// All tokens that were fed into the parser.
1360    pub fn tokens(&self) -> &[Rank] {
1361        &self.tokens
1362    }
1363
1364    /// Expose the current state as a JSON string for Python interop.
1365    pub fn state_json(&self) -> anyhow::Result<String> {
1366        #[derive(serde::Serialize)]
1367        #[serde(tag = "state")]
1368        enum SerializableStreamState<'a> {
1369            ExpectStart,
1370            Header {
1371                header_tokens: &'a [Rank],
1372            },
1373            Content {
1374                header: &'a ParsedHeader,
1375                content_tokens: &'a [Rank],
1376            },
1377        }
1378        let serializable = match &self.state {
1379            StreamState::ExpectStart => SerializableStreamState::ExpectStart,
1380            StreamState::Header { header_tokens } => {
1381                SerializableStreamState::Header { header_tokens }
1382            }
1383            StreamState::Content {
1384                header,
1385                content_tokens,
1386            } => SerializableStreamState::Content {
1387                header,
1388                content_tokens,
1389            },
1390        };
1391        Ok(serde_json::to_string(&serializable)?)
1392    }
1393
1394    /// Return the current recipient if known.
1395    pub fn current_recipient(&self) -> Option<String> {
1396        match &self.state {
1397            StreamState::Content { header, .. } => header.recipient.clone(),
1398            _ => None,
1399        }
1400    }
1401
1402    /// Return the current channel if known.
1403    pub fn current_channel(&self) -> Option<String> {
1404        match &self.state {
1405            StreamState::Content { header, .. } => header.channel.clone(),
1406            _ => None,
1407        }
1408    }
1409}
1410
1411// Add config struct for rendering
1412#[derive(Clone, Debug)]
1413pub struct RenderConversationConfig {
1414    pub auto_drop_analysis: bool,
1415}
1416
1417impl Default for RenderConversationConfig {
1418    fn default() -> Self {
1419        Self {
1420            auto_drop_analysis: true,
1421        }
1422    }
1423}