Skip to main content

gproxy_protocol/transform/identity/
allocator.rs

1use super::{
2    error::IdentityError,
3    tool_alias,
4    types::{DialectId, IdNamespace, IdentityRole, KnownIdPrefix, SourceIdentity},
5};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeSet, HashMap, HashSet};
8
9/// A target-specific syntax restriction, used only when supported by the
10/// target contract. No vendor-wide character restriction is assumed by default.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum IdSyntax {
13    AnyString,
14    AsciiIdentifier,
15}
16
17/// Target validation used before preserving an upstream id or emitting a
18/// generated id. Wire adapters can tighten this for a particular target.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TargetIdPolicy {
21    pub dialect: DialectId,
22    pub max_len: Option<usize>,
23    pub preserve_source_ids: bool,
24    pub syntax: IdSyntax,
25    pub required_prefix: Option<KnownIdPrefix>,
26    pub allowed_generated_prefixes: Option<Vec<KnownIdPrefix>>,
27    pub generated_prefix_overrides: Vec<(IdentityRole, KnownIdPrefix)>,
28    /// Emit a converted tool call under the reversible alias of
29    /// [`super::tool_alias`] whenever its upstream ID cannot go out as-is, so
30    /// a later turn recovers the upstream side from the alias alone. Only a
31    /// policy for IDs the client sends back sets this; IDs sent upstream are
32    /// never decoded.
33    #[serde(default)]
34    pub reversible_tool_calls: bool,
35}
36
37impl TargetIdPolicy {
38    pub fn new(dialect: DialectId) -> Self {
39        Self {
40            dialect,
41            max_len: None,
42            preserve_source_ids: true,
43            syntax: IdSyntax::AnyString,
44            required_prefix: None,
45            allowed_generated_prefixes: None,
46            generated_prefix_overrides: Vec::new(),
47            reversible_tool_calls: false,
48        }
49    }
50
51    pub fn with_reversible_tool_calls(mut self) -> Self {
52        self.reversible_tool_calls = true;
53        self
54    }
55
56    pub fn with_generated_prefixes(
57        mut self,
58        prefixes: impl IntoIterator<Item = KnownIdPrefix>,
59    ) -> Self {
60        self.allowed_generated_prefixes = Some(prefixes.into_iter().collect());
61        self
62    }
63
64    pub fn with_generated_prefix(mut self, role: IdentityRole, prefix: KnownIdPrefix) -> Self {
65        self.generated_prefix_overrides.push((role, prefix));
66        self
67    }
68
69    pub fn with_max_len(mut self, max_len: usize) -> Self {
70        self.max_len = Some(max_len);
71        self
72    }
73
74    pub fn with_syntax(mut self, syntax: IdSyntax) -> Self {
75        self.syntax = syntax;
76        self
77    }
78
79    /// Only set this when the target's actual contract requires the prefix.
80    /// Conventional generated prefixes alone do not justify this restriction.
81    pub fn with_required_prefix(mut self, prefix: KnownIdPrefix) -> Self {
82        self.required_prefix = Some(prefix);
83        self
84    }
85
86    fn accepts_value(&self, value: &str) -> bool {
87        valid_id(value, self.max_len)
88            && (self.syntax == IdSyntax::AnyString
89                || value
90                    .bytes()
91                    .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
92            && self.required_prefix.is_none_or(|p| {
93                value
94                    .strip_prefix(p.as_str())
95                    .is_some_and(|suffix| !suffix.is_empty())
96            })
97    }
98
99    pub fn accepts_source(&self, value: &str) -> bool {
100        self.preserve_source_ids && self.accepts_value(value)
101    }
102
103    pub fn accepts_generated(&self, prefix: KnownIdPrefix, value: &str) -> bool {
104        self.allowed_generated_prefixes
105            .as_ref()
106            .is_none_or(|prefixes| prefixes.contains(&prefix))
107            && self.accepts_value(value)
108    }
109}
110
111fn valid_id(value: &str, max_len: Option<usize>) -> bool {
112    !value.is_empty() && max_len.is_none_or(|max| value.len() <= max)
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116struct Locator {
117    role: IdentityRole,
118    dialect: DialectId,
119    logical_index: u64,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Hash)]
123struct EmittedLookup {
124    role: IdentityRole,
125    id: String,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Hash)]
129struct SourceLookup {
130    role: IdentityRole,
131    dialect: DialectId,
132    source_id: String,
133}
134
135#[derive(Debug, Clone)]
136struct Entry {
137    source: SourceIdentity,
138    source_role: IdentityRole,
139    emitted_id: String,
140}
141
142/// An immutable identity allocated within one invocation/stream flow.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct IdentityHandle {
145    pub role: IdentityRole,
146    pub source_role: IdentityRole,
147    namespace: IdNamespace,
148    pub source: SourceIdentity,
149    pub emitted_id: String,
150}
151
152impl IdentityHandle {
153    /// The id exposed on the target/client wire.
154    pub fn client_id(&self) -> &str {
155        &self.emitted_id
156    }
157
158    /// The source/native id, if the source supplied one (including a late
159    /// source id attached after the client alias was emitted).
160    pub fn source_id(&self) -> Option<&str> {
161        self.source.source_id.as_deref()
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct CallResultLink {
167    pub call_id: String,
168    pub output_item_id: Option<String>,
169}
170
171/// Flow-local identity map. It must be created for each invocation; it does
172/// not contain process-global state or random number generation.
173#[derive(Debug, Clone)]
174pub struct IdentityFlow {
175    namespace: IdNamespace,
176    used_ids: HashSet<EmittedLookup>,
177    entries: HashMap<Locator, Entry>,
178    by_source: HashMap<SourceLookup, Locator>,
179    by_emitted: HashMap<EmittedLookup, Locator>,
180    bound_target: Option<TargetIdPolicy>,
181}
182
183impl IdentityFlow {
184    pub fn new(namespace: IdNamespace) -> Self {
185        Self {
186            namespace,
187            used_ids: HashSet::new(),
188            entries: HashMap::new(),
189            by_source: HashMap::new(),
190            by_emitted: HashMap::new(),
191            bound_target: None,
192        }
193    }
194
195    /// Reserve identities owned by other calls without inventing source associations.
196    pub(crate) fn reserve_external_ids(
197        &mut self,
198        role: IdentityRole,
199        ids: &BTreeSet<String>,
200    ) -> Result<(), IdentityError> {
201        if ids.iter().any(|id| {
202            self.by_emitted.contains_key(&EmittedLookup {
203                role,
204                id: id.clone(),
205            })
206        }) {
207            return Err(IdentityError::InvalidIdentity(
208                "external identity already emitted by this call".into(),
209            ));
210        }
211        for id in ids {
212            self.used_ids.insert(EmittedLookup {
213                role,
214                id: id.clone(),
215            });
216        }
217        Ok(())
218    }
219
220    /// Current associations, including source IDs learned after first emission.
221    /// Iterator order is unspecified; persistence callers must sort their keys.
222    pub fn handles(&self) -> impl Iterator<Item = IdentityHandle> + '_ {
223        self.entries
224            .iter()
225            .map(|(locator, entry)| handle_from_entry(self.namespace, locator.role, entry))
226    }
227
228    pub fn namespace(&self) -> IdNamespace {
229        self.namespace
230    }
231
232    /// Resolve an identity whose semantic role is unchanged across the edge.
233    pub fn resolve_or_allocate(
234        &mut self,
235        role: IdentityRole,
236        source: SourceIdentity,
237        target: &TargetIdPolicy,
238    ) -> Result<IdentityHandle, IdentityError> {
239        self.resolve_as(role, role, source, target)
240    }
241
242    /// Re-identify across semantic roles. A call id is not reused as an output
243    /// item id even when both destinations accept arbitrary string identifiers.
244    pub fn resolve_as(
245        &mut self,
246        source_role: IdentityRole,
247        role: IdentityRole,
248        source: SourceIdentity,
249        target: &TargetIdPolicy,
250    ) -> Result<IdentityHandle, IdentityError> {
251        self.resolve_inner(source_role, role, source, target, &BTreeSet::new(), false)
252    }
253
254    /// Resolve a legacy Chat `function_call`. It has no ID, and its client
255    /// alias says so, so a later turn to a Chat upstream sends it back in the
256    /// legacy form rather than as a modern call.
257    pub fn resolve_legacy_chat_call(
258        &mut self,
259        source: SourceIdentity,
260        target: &TargetIdPolicy,
261    ) -> Result<IdentityHandle, IdentityError> {
262        let role = IdentityRole::ToolCall;
263        self.resolve_inner(role, role, source, target, &BTreeSet::new(), true)
264    }
265
266    /// Like [`Self::resolve_or_allocate`], but a new identity never takes an
267    /// ID in `avoid`: one another call of the same client response owns.
268    pub fn resolve_or_allocate_avoiding(
269        &mut self,
270        role: IdentityRole,
271        source: SourceIdentity,
272        target: &TargetIdPolicy,
273        avoid: &BTreeSet<String>,
274    ) -> Result<IdentityHandle, IdentityError> {
275        self.resolve_inner(role, role, source, target, avoid, false)
276    }
277
278    fn resolve_inner(
279        &mut self,
280        source_role: IdentityRole,
281        role: IdentityRole,
282        mut source: SourceIdentity,
283        target: &TargetIdPolicy,
284        avoid: &BTreeSet<String>,
285        legacy: bool,
286    ) -> Result<IdentityHandle, IdentityError> {
287        source.source_id = source.source_id.filter(|id| !id.is_empty());
288        if let Some(bound) = &self.bound_target
289            && bound != target
290        {
291            return Err(IdentityError::InvalidIdentity(
292                "identity flow target policy changed after allocation".into(),
293            ));
294        }
295        let locator = Locator {
296            role,
297            dialect: source.dialect,
298            logical_index: source.logical_index,
299        };
300        if let Some(source_id) = source.source_id.as_deref() {
301            let source_key = SourceLookup {
302                role,
303                dialect: source.dialect,
304                source_id: source_id.to_owned(),
305            };
306            if let Some(previous) = self.by_source.get(&source_key)
307                && previous.logical_index != source.logical_index
308            {
309                return Err(IdentityError::DuplicateSourceIdentity {
310                    role: role.name(),
311                    dialect: source.dialect.id().to_owned(),
312                    source_id: source_id.to_owned(),
313                    first_index: previous.logical_index,
314                    duplicate_index: source.logical_index,
315                });
316            }
317            if let Some(entry) = self.entries.get_mut(&locator) {
318                if entry.source_role != source_role {
319                    return Err(IdentityError::InvalidIdentity(
320                        "source role changed at logical position".into(),
321                    ));
322                }
323                if entry.source.source_id.as_deref() != Some(source_id) {
324                    attach_source_inner(
325                        &mut self.by_source,
326                        &source_key,
327                        &locator,
328                        &mut entry.source,
329                    )?;
330                }
331                if self.bound_target.is_none() {
332                    self.bound_target = Some(target.clone());
333                }
334                return Ok(handle_from_entry(self.namespace, role, entry));
335            }
336        } else if role == IdentityRole::Resource {
337            return Err(IdentityError::ResourceIdRequired);
338        }
339
340        if let Some(entry) = self.entries.get(&locator) {
341            if entry.source_role != source_role {
342                return Err(IdentityError::InvalidIdentity(
343                    "source role changed at logical position".into(),
344                ));
345            }
346            if self.bound_target.is_none() {
347                self.bound_target = Some(target.clone());
348            }
349            return Ok(handle_from_entry(self.namespace, role, entry));
350        }
351
352        let emitted_id = match (source.source_id.as_deref(), role) {
353            (_, IdentityRole::ToolCall)
354                if source_role == role
355                    && target.reversible_tool_calls
356                    && source.dialect != target.dialect =>
357            {
358                self.tool_alias(&source, target, avoid, legacy)?
359            }
360            (Some(source_id), _)
361                if source_role == role
362                    && target.accepts_source(source_id)
363                    && !avoid.contains(source_id)
364                    && !self.used_ids.contains(&EmittedLookup {
365                        role,
366                        id: source_id.to_owned(),
367                    }) =>
368            {
369                source_id.to_owned()
370            }
371            (Some(_), IdentityRole::Resource) => return Err(IdentityError::ResourceIdRequired),
372            (_, IdentityRole::Resource) => return Err(IdentityError::ResourceIdRequired),
373            _ => self.allocate_generated(role, source.dialect, source.logical_index, target)?,
374        };
375        let entry = Entry {
376            source: source.clone(),
377            source_role,
378            emitted_id: emitted_id.clone(),
379        };
380        self.used_ids.insert(EmittedLookup {
381            role,
382            id: emitted_id.clone(),
383        });
384        self.by_emitted.insert(
385            EmittedLookup {
386                role,
387                id: emitted_id.clone(),
388            },
389            locator.clone(),
390        );
391        if let Some(source_id) = source.source_id.as_deref() {
392            self.by_source.insert(
393                SourceLookup {
394                    role,
395                    dialect: source.dialect,
396                    source_id: source_id.to_owned(),
397                },
398                locator.clone(),
399            );
400        }
401        self.entries.insert(locator, entry.clone());
402        self.bound_target = Some(target.clone());
403        Ok(handle_from_entry(self.namespace, role, &entry))
404    }
405
406    /// A converted call goes out under its upstream ID when the client's
407    /// dialect takes it and it cannot be mistaken for an alias; otherwise
408    /// under the reversible alias that names it. No per-request namespace
409    /// enters an alias for an upstream ID, so the same ID always gets the
410    /// same alias. A repeated ID counts up until the alias is free.
411    fn tool_alias(
412        &self,
413        source: &SourceIdentity,
414        target: &TargetIdPolicy,
415        avoid: &BTreeSet<String>,
416        legacy: bool,
417    ) -> Result<String, IdentityError> {
418        let prefix = generated_prefix(IdentityRole::ToolCall, target)?;
419        let free = |id: &str| {
420            !avoid.contains(id)
421                && !self.used_ids.contains(&EmittedLookup {
422                    role: IdentityRole::ToolCall,
423                    id: id.to_owned(),
424                })
425        };
426        let Some(id) = source.source_id.as_deref() else {
427            let namespace = self.namespace.hex();
428            let alias = tool_alias::missing(
429                prefix.as_str(),
430                &namespace[..16],
431                source.logical_index,
432                legacy,
433            );
434            if !target.accepts_generated(prefix, &alias) {
435                return Err(IdentityError::InvalidIdentity(
436                    "target policy cannot accept generated identifier".into(),
437                ));
438            }
439            if !free(&alias) {
440                return Err(IdentityError::Collision(alias));
441            }
442            return Ok(alias);
443        };
444        if target.accepts_source(id)
445            && tool_alias::accepts(target.dialect, id)
446            && tool_alias::decode(id).is_none()
447            && free(id)
448        {
449            return Ok(id.to_owned());
450        }
451        // Each taken alias rules out at most one count, so this always ends.
452        let tries = self.used_ids.len() as u64 + avoid.len() as u64;
453        for repeat in 0..=tries {
454            let alias = tool_alias::upstream(prefix.as_str(), id, repeat);
455            if !target.accepts_generated(prefix, &alias) {
456                return Err(IdentityError::InvalidIdentity(
457                    "target policy cannot accept a tool call alias".into(),
458                ));
459            }
460            if free(&alias) {
461                return Ok(alias);
462            }
463        }
464        Err(IdentityError::Collision(id.to_owned()))
465    }
466
467    fn allocate_generated(
468        &self,
469        role: IdentityRole,
470        source_dialect: DialectId,
471        logical_index: u64,
472        target: &TargetIdPolicy,
473    ) -> Result<String, IdentityError> {
474        let prefix = generated_prefix(role, target)?;
475        let candidate = format!(
476            "{}{}_{}_{}_{:x}",
477            prefix.as_str(),
478            self.namespace.hex(),
479            dialect_key(source_dialect),
480            role.allocation_key(),
481            logical_index
482        );
483        if !target.accepts_generated(prefix, &candidate) {
484            return Err(IdentityError::InvalidIdentity(
485                "target policy cannot accept generated identifier".into(),
486            ));
487        }
488        if self.used_ids.contains(&EmittedLookup {
489            role,
490            id: candidate.clone(),
491        }) {
492            // Do not resolve a collision using arrival order: fail deterministically.
493            return Err(IdentityError::Collision(candidate));
494        }
495        Ok(candidate)
496    }
497
498    /// Attach a source id that arrived after the emitted alias was sent. The
499    /// returned alias is always the original immutable alias.
500    pub fn attach_source(
501        &mut self,
502        handle: &IdentityHandle,
503        source_id: impl Into<String>,
504    ) -> Result<IdentityHandle, IdentityError> {
505        let source_id = source_id.into();
506        if source_id.is_empty() {
507            return Err(IdentityError::InvalidIdentity(
508                "late source id is invalid".into(),
509            ));
510        }
511        let locator = self
512            .by_emitted
513            .get(&EmittedLookup {
514                role: handle.role,
515                id: handle.emitted_id.clone(),
516            })
517            .cloned()
518            .ok_or_else(|| IdentityError::AmbiguousSourceIdentity("unknown emitted id".into()))?;
519        let entry = self
520            .entries
521            .get_mut(&locator)
522            .ok_or_else(|| IdentityError::AmbiguousSourceIdentity("missing flow entry".into()))?;
523        let source_key = SourceLookup {
524            role: locator.role,
525            dialect: locator.dialect,
526            source_id: source_id.clone(),
527        };
528        attach_source_inner(
529            &mut self.by_source,
530            &source_key,
531            &locator,
532            &mut entry.source,
533        )?;
534        Ok(handle_from_entry(self.namespace, locator.role, entry))
535    }
536
537    pub fn lookup_source(
538        &self,
539        source: &SourceIdentity,
540        role: IdentityRole,
541    ) -> Option<IdentityHandle> {
542        let source_id = source.source_id.as_deref()?;
543        let key = SourceLookup {
544            role,
545            dialect: source.dialect,
546            source_id: source_id.to_owned(),
547        };
548        let locator = self.by_source.get(&key)?;
549        self.entries
550            .get(locator)
551            .map(|entry| handle_from_entry(self.namespace, role, entry))
552    }
553
554    /// Look up an entry when a stream event has no source id yet.
555    pub fn lookup_logical(
556        &self,
557        role: IdentityRole,
558        dialect: &DialectId,
559        logical_index: u64,
560    ) -> Option<IdentityHandle> {
561        let locator = Locator {
562            role,
563            dialect: *dialect,
564            logical_index,
565        };
566        self.entries
567            .get(&locator)
568            .map(|entry| handle_from_entry(self.namespace, role, entry))
569    }
570
571    pub fn lookup_emitted(&self, emitted_id: &str) -> Option<IdentityHandle> {
572        let mut matches = self
573            .by_emitted
574            .iter()
575            .filter(|(key, _)| key.id == emitted_id);
576        let (_, locator) = matches.next()?;
577        if matches.next().is_some() {
578            return None;
579        }
580        self.entries
581            .get(locator)
582            .map(|entry| handle_from_entry(self.namespace, locator.role, entry))
583    }
584
585    pub fn lookup_emitted_as(
586        &self,
587        role: IdentityRole,
588        emitted_id: &str,
589    ) -> Option<IdentityHandle> {
590        let locator = self.by_emitted.get(&EmittedLookup {
591            role,
592            id: emitted_id.to_owned(),
593        })?;
594        self.entries
595            .get(locator)
596            .map(|entry| handle_from_entry(self.namespace, role, entry))
597    }
598
599    pub fn lookup_client_id(&self, client_id: &str) -> Option<IdentityHandle> {
600        self.lookup_emitted(client_id)
601    }
602
603    pub fn lookup_native(
604        &self,
605        source: &SourceIdentity,
606        role: IdentityRole,
607    ) -> Option<IdentityHandle> {
608        self.lookup_source(source, role)
609    }
610
611    pub fn link_call_result(
612        &self,
613        call: &IdentityHandle,
614        result: Option<&IdentityHandle>,
615    ) -> Result<CallResultLink, IdentityError> {
616        if call.role != IdentityRole::ToolCall {
617            return Err(IdentityError::InvalidIdentity(
618                "call-result link requires a ToolCall".into(),
619            ));
620        }
621        if result.is_some_and(|result| {
622            !matches!(
623                result.role,
624                IdentityRole::OutputItem(
625                    super::types::OutputItemKind::FunctionCallOutput
626                        | super::types::OutputItemKind::CustomToolCallOutput
627                )
628            )
629        }) {
630            return Err(IdentityError::InvalidIdentity(
631                "call-result link requires a function/custom result item".into(),
632            ));
633        }
634        Ok(CallResultLink {
635            call_id: call.emitted_id.clone(),
636            output_item_id: result.map(|result| result.emitted_id.clone()),
637        })
638    }
639}
640
641fn attach_source_inner(
642    by_source: &mut HashMap<SourceLookup, Locator>,
643    key: &SourceLookup,
644    locator: &Locator,
645    source: &mut SourceIdentity,
646) -> Result<(), IdentityError> {
647    if let Some(previous) = by_source.get(key)
648        && previous != locator
649    {
650        return Err(IdentityError::DuplicateSourceIdentity {
651            role: locator.role.name(),
652            dialect: locator.dialect.id().to_owned(),
653            source_id: key.source_id.clone(),
654            first_index: previous.logical_index,
655            duplicate_index: locator.logical_index,
656        });
657    }
658    if let Some(existing) = source.source_id.as_deref()
659        && existing != key.source_id
660    {
661        return Err(IdentityError::AmbiguousSourceIdentity(format!(
662            "entry already has source id {existing:?}"
663        )));
664    }
665    source.source_id = Some(key.source_id.clone());
666    by_source.insert(key.clone(), locator.clone());
667    Ok(())
668}
669
670fn handle_from_entry(namespace: IdNamespace, role: IdentityRole, entry: &Entry) -> IdentityHandle {
671    IdentityHandle {
672        role,
673        source_role: entry.source_role,
674        namespace,
675        source: entry.source.clone(),
676        emitted_id: entry.emitted_id.clone(),
677    }
678}
679
680fn generated_prefix(
681    role: IdentityRole,
682    target: &TargetIdPolicy,
683) -> Result<KnownIdPrefix, IdentityError> {
684    target
685        .generated_prefix_overrides
686        .iter()
687        .rev()
688        .find_map(|(r, p)| (*r == role).then_some(*p))
689        .or_else(|| role.generated_prefix())
690        .ok_or(IdentityError::NoGeneratedPrefix)
691}
692
693fn dialect_key(dialect: DialectId) -> &'static str {
694    match dialect {
695        DialectId::OpenAi => "o",
696        DialectId::Claude => "c",
697        DialectId::Gemini => "g",
698        DialectId::OpenAiChat => "h",
699        DialectId::OpenAiResponsesWebSocket => "w",
700    }
701}