Skip to main content

tokn_headers/schemas/personas/
opencode.rs

1//! Headers emitted by the OpenCode CLI client.
2//!
3//! Field set derived from the inbound real-world matrix (see
4//! `tests/fixtures/inbound_real_world.json`). Required fields are present in
5//! ≥99% of captured requests; optional fields are observed but inconsistent.
6//!
7//! `Authorization` is modelled as required even though its value may be the
8//! literal `"<redacted>"` in fixtures: the *header* is universally present,
9//! and downstream layers replace its value before transmission.
10
11use crate::error::Error;
12use crate::keys;
13use crate::map::HeaderMap;
14use crate::name::HeaderName;
15use crate::schema::{from_inbound_or, opt_from_inbound, optional, put, put_opt, required, HeaderSchema};
16use crate::vars::TemplateVars;
17use serde::{Deserialize, Serialize};
18use smol_str::SmolStr;
19
20/// Inbound headers consistently emitted by the OpenCode CLI persona.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct OpencodeHeaders {
23  // Transport (always present)
24  #[serde(rename = "User-Agent")]
25  pub user_agent: SmolStr,
26  #[serde(rename = "Authorization")]
27  pub authorization: SmolStr,
28  /// Optional. NEVER stamped from a persona default: the persona-default
29  /// host (e.g. `api.deepseek.com`) is wrong for any other upstream and
30  /// caused real 403s when it leaked into Send. `build` only sets this
31  /// from inbound traffic; outbound transport derives `Host` from the URL.
32  #[serde(rename = "Host", skip_serializing_if = "Option::is_none")]
33  pub host: Option<SmolStr>,
34  #[serde(rename = "Accept")]
35  pub accept: SmolStr,
36  #[serde(rename = "Accept-Encoding")]
37  pub accept_encoding: SmolStr,
38  #[serde(rename = "Connection")]
39  pub connection: SmolStr,
40  #[serde(rename = "Content-Type")]
41  pub content_type: SmolStr,
42
43  // Body framing (present on every POST; absent on GET /models)
44  #[serde(rename = "Content-Length", skip_serializing_if = "Option::is_none")]
45  pub content_length: Option<SmolStr>,
46
47  // Session correlation (X-Session-Affinity is the inbound name; X-Session-Id
48  // is router-injected and never appears in opencode-emitted captures).
49  #[serde(rename = "X-Session-Affinity", skip_serializing_if = "Option::is_none")]
50  pub session_affinity: Option<SmolStr>,
51  #[serde(rename = "X-Parent-Session-Id", skip_serializing_if = "Option::is_none")]
52  pub parent_session_id: Option<SmolStr>,
53}
54
55impl HeaderSchema for OpencodeHeaders {
56  fn parse(map: &HeaderMap) -> Result<Self, Error> {
57    Ok(Self {
58      user_agent: required(map, &keys::USER_AGENT)?,
59      authorization: required(map, &keys::AUTHORIZATION)?,
60      host: optional(map, &keys::HOST),
61      accept: required(map, &keys::ACCEPT)?,
62      accept_encoding: required(map, &keys::ACCEPT_ENCODING)?,
63      connection: required(map, &keys::CONNECTION)?,
64      content_type: required(map, &keys::CONTENT_TYPE)?,
65      content_length: optional(map, &keys::CONTENT_LENGTH),
66      session_affinity: optional(map, &keys::X_SESSION_AFFINITY),
67      parent_session_id: optional(map, &keys::X_PARENT_SESSION_ID),
68    })
69  }
70  fn dump(&self) -> HeaderMap {
71    let mut m = HeaderMap::new();
72    put(&mut m, &keys::USER_AGENT, &self.user_agent);
73    put(&mut m, &keys::AUTHORIZATION, &self.authorization);
74    put_opt(&mut m, &keys::HOST, &self.host);
75    put(&mut m, &keys::ACCEPT, &self.accept);
76    put(&mut m, &keys::ACCEPT_ENCODING, &self.accept_encoding);
77    put(&mut m, &keys::CONNECTION, &self.connection);
78    put(&mut m, &keys::CONTENT_TYPE, &self.content_type);
79    put_opt(&mut m, &keys::CONTENT_LENGTH, &self.content_length);
80    put_opt(&mut m, &keys::X_SESSION_AFFINITY, &self.session_affinity);
81    put_opt(&mut m, &keys::X_PARENT_SESSION_ID, &self.parent_session_id);
82    m
83  }
84  fn known_names() -> &'static [&'static HeaderName] {
85    static NAMES: [&HeaderName; 10] = [
86      &keys::USER_AGENT,
87      &keys::AUTHORIZATION,
88      &keys::HOST,
89      &keys::ACCEPT,
90      &keys::ACCEPT_ENCODING,
91      &keys::CONNECTION,
92      &keys::CONTENT_TYPE,
93      &keys::CONTENT_LENGTH,
94      &keys::X_SESSION_AFFINITY,
95      &keys::X_PARENT_SESSION_ID,
96    ];
97    &NAMES
98  }
99}
100
101impl OpencodeHeaders {
102  /// Build an [`OpencodeHeaders`] from inbound transport headers and
103  /// correlation [`TemplateVars`]. Inbound values win for transport fields;
104  /// correlation fields prefer `vars`. Missing required fields fall back to
105  /// persona-specific defaults derived from real captured traffic.
106  pub fn build(vars: &TemplateVars, inbound: &HeaderMap) -> Self {
107    Self {
108      user_agent: from_inbound_or(inbound, &keys::USER_AGENT, || {
109        "opencode/1.14.28 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13".into()
110      }),
111      authorization: from_inbound_or(inbound, &keys::AUTHORIZATION, || "<missing>".into()),
112      host: None,
113      accept: from_inbound_or(inbound, &keys::ACCEPT, || "*/*".into()),
114      accept_encoding: from_inbound_or(inbound, &keys::ACCEPT_ENCODING, || "gzip, deflate, br, zstd".into()),
115      connection: from_inbound_or(inbound, &keys::CONNECTION, || "keep-alive".into()),
116      content_type: from_inbound_or(inbound, &keys::CONTENT_TYPE, || "application/json".into()),
117      content_length: opt_from_inbound(inbound, &keys::CONTENT_LENGTH),
118      session_affinity: vars
119        .session_id
120        .clone()
121        .or_else(|| opt_from_inbound(inbound, &keys::X_SESSION_AFFINITY)),
122      parent_session_id: opt_from_inbound(inbound, &keys::X_PARENT_SESSION_ID),
123    }
124  }
125}
126
127#[cfg(test)]
128mod tests {
129  use super::*;
130
131  fn sample() -> OpencodeHeaders {
132    OpencodeHeaders {
133      user_agent: "opencode/1.14.28 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13".into(),
134      authorization: "<redacted>".into(),
135      host: Some("api.deepseek.com".into()),
136      accept: "*/*".into(),
137      accept_encoding: "gzip, deflate, br, zstd".into(),
138      connection: "keep-alive".into(),
139      content_type: "application/json".into(),
140      content_length: Some("4429".into()),
141      session_affinity: Some("ses_1dddd2016ffed1A1u3yj5LmNWC".into()),
142      parent_session_id: None,
143    }
144  }
145
146  #[test]
147  fn opencode_round_trip() {
148    let h = sample();
149    let parsed = OpencodeHeaders::parse(&h.dump()).unwrap();
150    assert_eq!(parsed, h);
151  }
152
153  #[test]
154  fn opencode_optional_fields_omitted_when_none() {
155    let mut h = sample();
156    h.content_length = None;
157    h.session_affinity = None;
158    h.parent_session_id = None;
159    let m = h.dump();
160    // 7 required fields, 0 optional written.
161    assert_eq!(m.len(), 7);
162    assert!(!m.contains_key(&keys::CONTENT_LENGTH));
163    assert!(!m.contains_key(&keys::X_SESSION_AFFINITY));
164  }
165
166  #[test]
167  fn opencode_missing_required_returns_error() {
168    let m = HeaderMap::new();
169    assert!(matches!(OpencodeHeaders::parse(&m), Err(Error::MissingHeader { .. })));
170  }
171
172  #[test]
173  fn build_with_empty_inbound_uses_defaults() {
174    let h = OpencodeHeaders::build(&TemplateVars::default(), &HeaderMap::new());
175    assert_eq!(
176      h.user_agent.as_str(),
177      "opencode/1.14.28 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13"
178    );
179    assert_eq!(h.authorization.as_str(), "<missing>");
180    assert!(
181      h.host.is_none(),
182      "no inbound Host => no persona-default Host (would leak to wire)"
183    );
184    assert_eq!(h.accept.as_str(), "*/*");
185    assert_eq!(h.accept_encoding.as_str(), "gzip, deflate, br, zstd");
186    assert_eq!(h.connection.as_str(), "keep-alive");
187    assert_eq!(h.content_type.as_str(), "application/json");
188    assert!(h.content_length.is_none());
189    assert!(h.session_affinity.is_none());
190    assert!(h.parent_session_id.is_none());
191  }
192
193  #[test]
194  fn build_passes_through_inbound() {
195    let mut inbound = HeaderMap::new();
196    inbound.insert(&keys::USER_AGENT, "custom-ua/9.9");
197    inbound.insert(&keys::AUTHORIZATION, "Bearer secret");
198    inbound.insert(&keys::CONTENT_LENGTH, "1234");
199    inbound.insert(&keys::HOST, "api.deepseek.com");
200    let h = OpencodeHeaders::build(&TemplateVars::default(), &inbound);
201    assert_eq!(h.user_agent.as_str(), "custom-ua/9.9");
202    assert_eq!(h.authorization.as_str(), "Bearer secret");
203    assert_eq!(h.content_length.as_deref(), Some("1234"));
204    assert_eq!(h.host.as_deref(), None);
205  }
206
207  #[test]
208  fn build_uses_vars_for_correlation() {
209    let vars = TemplateVars {
210      session_id: Some("ses_xyz".into()),
211      ..Default::default()
212    };
213    let h = OpencodeHeaders::build(&vars, &HeaderMap::new());
214    assert_eq!(h.session_affinity.as_deref(), Some("ses_xyz"));
215  }
216}