use std::sync::Arc;
use jiff::tz::TimeZone as JiffTimeZone;
use serde::Deserialize;
use crate::{
LoadedEntry, PricingMap, TokenUsageRaw, UsageEntry, UsageMessage, apply_total_token_fallback,
calculate_cost_for_usage_at, cli::CostMode, format_date_tz,
missing_pricing_model_for_candidates,
};
use csusage_adapter_common::jsonl;
#[derive(Debug, Default, Deserialize)]
pub struct OpenCodeMessage {
#[serde(default, deserialize_with = "jsonl::lenient_object")]
tokens: Option<OpenCodeTokens>,
#[serde(
rename = "modelID",
default,
deserialize_with = "jsonl::non_empty_string"
)]
model_id: Option<String>,
#[serde(
rename = "providerID",
default,
deserialize_with = "jsonl::non_empty_string"
)]
provider_id: Option<String>,
#[serde(default, deserialize_with = "jsonl::lenient_object")]
time: Option<OpenCodeTime>,
#[serde(default, deserialize_with = "jsonl::non_empty_string")]
id: Option<String>,
#[serde(
rename = "sessionID",
default,
deserialize_with = "jsonl::non_empty_string"
)]
session_id: Option<String>,
#[serde(default, deserialize_with = "jsonl::lenient_f64")]
cost: Option<f64>,
}
#[derive(Debug, Default, Deserialize)]
struct OpenCodeTokens {
#[serde(default, deserialize_with = "jsonl::lenient_u64")]
input: u64,
#[serde(default, deserialize_with = "jsonl::lenient_u64")]
output: u64,
#[serde(default, deserialize_with = "jsonl::lenient_u64")]
reasoning: u64,
#[serde(default, deserialize_with = "jsonl::lenient_object")]
cache: Option<OpenCodeCache>,
#[serde(default, deserialize_with = "jsonl::lenient_u64")]
total: u64,
}
#[derive(Debug, Default, Deserialize)]
struct OpenCodeCache {
#[serde(default, deserialize_with = "jsonl::lenient_u64")]
read: u64,
#[serde(default, deserialize_with = "jsonl::lenient_u64")]
write: u64,
}
#[derive(Debug, Default, Deserialize)]
struct OpenCodeTime {
#[serde(default, deserialize_with = "jsonl::lenient_i64")]
created: Option<i64>,
}
#[derive(Debug, Default, Deserialize)]
struct OpenCodeModelReference {
#[serde(default, deserialize_with = "jsonl::non_empty_string")]
id: Option<String>,
#[serde(
rename = "modelID",
default,
deserialize_with = "jsonl::non_empty_string"
)]
model_id: Option<String>,
#[serde(
rename = "providerID",
default,
deserialize_with = "jsonl::non_empty_string"
)]
provider_id: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct OpenCodeV2Message {
#[serde(default, deserialize_with = "jsonl::lenient_object")]
model: Option<OpenCodeModelReference>,
#[serde(
rename = "modelID",
default,
deserialize_with = "jsonl::non_empty_string"
)]
model_id: Option<String>,
#[serde(
rename = "providerID",
default,
deserialize_with = "jsonl::non_empty_string"
)]
provider_id: Option<String>,
#[serde(default, deserialize_with = "jsonl::lenient_object")]
tokens: Option<OpenCodeTokens>,
#[serde(default, deserialize_with = "jsonl::lenient_object")]
time: Option<OpenCodeTime>,
#[serde(default, deserialize_with = "jsonl::lenient_f64")]
cost: Option<f64>,
}
impl OpenCodeV2Message {
fn into_legacy_message(self, id: String, session_id: String, created: i64) -> OpenCodeMessage {
let model_id = self
.model
.as_ref()
.and_then(|model| model.id.clone().or_else(|| model.model_id.clone()))
.or(self.model_id);
let provider_id = self
.model
.and_then(|model| model.provider_id)
.or(self.provider_id);
let time = Some(OpenCodeTime {
created: self.time.and_then(|time| time.created).or(Some(created)),
});
OpenCodeMessage {
tokens: self.tokens,
model_id,
provider_id,
time,
id: Some(id),
session_id: Some(session_id),
cost: self.cost,
}
}
}
pub fn message_value_to_entry(
value: &OpenCodeMessage,
id: Option<String>,
session_id: Option<String>,
tz: Option<&JiffTimeZone>,
mode: CostMode,
pricing: Option<&PricingMap>,
) -> Option<LoadedEntry> {
message_value_to_entry_inner(
value,
id,
session_id,
tz,
mode,
pricing,
MessageEntryOptions {
allow_cost_only: false,
pricing_timestamp: open_code_timestamp(value),
},
)
}
struct MessageEntryOptions {
allow_cost_only: bool,
pricing_timestamp: Option<crate::TimestampMs>,
}
fn message_value_to_entry_inner(
value: &OpenCodeMessage,
id: Option<String>,
session_id: Option<String>,
tz: Option<&JiffTimeZone>,
mode: CostMode,
pricing: Option<&PricingMap>,
options: MessageEntryOptions,
) -> Option<LoadedEntry> {
let tokens = value.tokens.as_ref()?;
let cache = tokens.cache.as_ref();
let usage = TokenUsageRaw {
input_tokens: tokens.input,
output_tokens: tokens.output,
cache_creation_input_tokens: cache.map_or(0, |cache| cache.write),
cache_read_input_tokens: cache.map_or(0, |cache| cache.read),
speed: None,
cache_creation: None,
};
let total_tokens = tokens.total;
let (usage, extra_total_tokens) =
apply_total_token_fallback(usage, tokens.reasoning, total_tokens);
if usage.input_tokens == 0
&& usage.output_tokens == 0
&& usage.cache_creation_input_tokens == 0
&& usage.cache_read_input_tokens == 0
&& extra_total_tokens == 0
&& !(options.allow_cost_only && value.cost.is_some_and(|cost| cost > 0.0))
{
return None;
}
let model = value.model_id.clone()?;
let provider = value.provider_id.clone()?;
let timestamp = open_code_timestamp(value).unwrap_or(crate::TimestampMs::UNIX_EPOCH);
let timestamp_text = crate::format_rfc3339_millis(timestamp);
let message_id = id.or_else(|| value.id.clone());
let session_id = session_id.or_else(|| value.session_id.clone());
let data = UsageEntry {
session_id: session_id.clone(),
timestamp: timestamp_text,
version: None,
message: UsageMessage {
usage,
model: Some(model.clone()),
id: message_id,
},
cost_usd: value.cost,
request_id: None,
is_api_error_message: None,
is_sidechain: None,
};
let cost_usage = TokenUsageRaw {
output_tokens: usage.output_tokens.saturating_add(extra_total_tokens),
cache_creation: None,
..usage
};
let cost = calculate_open_code_cost(
&model,
&provider,
cost_usage,
data.cost_usd,
options.pricing_timestamp,
mode,
pricing,
);
let missing_pricing_model = if cost > 0.0 {
None
} else {
missing_open_code_pricing(&model, &provider, cost_usage, data.cost_usd, mode, pricing)
};
let loaded_session_id = data
.session_id
.clone()
.unwrap_or_else(|| "unknown".to_string());
Some(LoadedEntry {
date: format_date_tz(timestamp, tz),
timestamp,
project: Arc::from("opencode"),
session_id: Arc::from(loaded_session_id),
project_path: Arc::from("OpenCode"),
cost,
extra_total_tokens,
credits: None,
message_count: None,
model: Some(model),
usage_limit_reset_time: None,
missing_pricing_model,
data,
})
}
pub(crate) fn session_message_value_to_entry(
data: &str,
id: String,
session_id: String,
created: i64,
tz: Option<&JiffTimeZone>,
mode: CostMode,
pricing: Option<&PricingMap>,
) -> Option<LoadedEntry> {
let value = serde_json::from_str::<OpenCodeV2Message>(data).ok()?;
let value = value.into_legacy_message(id, session_id, created);
message_value_to_entry(&value, None, None, tz, mode, pricing)
}
pub(crate) struct OpenCodeSessionAggregate {
pub(crate) session_id: String,
pub(crate) created: i64,
pub(crate) model: String,
pub(crate) provider: String,
pub(crate) input_tokens: u64,
pub(crate) output_tokens: u64,
pub(crate) reasoning_tokens: u64,
pub(crate) cache_read_tokens: u64,
pub(crate) cache_write_tokens: u64,
pub(crate) cost: Option<f64>,
}
pub(crate) fn session_value_to_entry(
aggregate: OpenCodeSessionAggregate,
tz: Option<&JiffTimeZone>,
mode: CostMode,
pricing: Option<&PricingMap>,
) -> Option<LoadedEntry> {
if aggregate.input_tokens == 0
&& aggregate.output_tokens == 0
&& aggregate.reasoning_tokens == 0
&& aggregate.cache_read_tokens == 0
&& aggregate.cache_write_tokens == 0
&& aggregate.cost.unwrap_or(0.0) <= 0.0
{
return None;
}
let value = OpenCodeMessage {
tokens: Some(OpenCodeTokens {
input: aggregate.input_tokens,
output: aggregate.output_tokens,
reasoning: aggregate.reasoning_tokens,
cache: Some(OpenCodeCache {
read: aggregate.cache_read_tokens,
write: aggregate.cache_write_tokens,
}),
total: 0,
}),
model_id: Some(aggregate.model),
provider_id: Some(aggregate.provider),
time: Some(OpenCodeTime {
created: Some(aggregate.created),
}),
id: Some(format!("session:{}", aggregate.session_id)),
session_id: Some(aggregate.session_id),
cost: aggregate.cost,
};
message_value_to_entry_inner(
&value,
None,
None,
tz,
mode,
pricing,
MessageEntryOptions {
allow_cost_only: true,
pricing_timestamp: None,
},
)
}
fn open_code_timestamp(value: &OpenCodeMessage) -> Option<crate::TimestampMs> {
value
.time
.as_ref()
.and_then(|time| time.created)
.filter(|millis| *millis > 0)
.map(crate::TimestampMs::from_millis)
}
fn calculate_open_code_cost(
model: &str,
provider: &str,
usage: TokenUsageRaw,
cost_usd: Option<f64>,
timestamp: Option<crate::TimestampMs>,
_mode: CostMode,
pricing: Option<&PricingMap>,
) -> f64 {
if let Some(cost) = cost_usd.filter(|cost| *cost > 0.0) {
return cost;
}
for candidate in open_code_model_candidates(model, provider) {
let cost = calculate_cost_for_usage_at(
Some(&candidate),
usage,
None,
timestamp,
CostMode::Calculate,
pricing,
);
if cost > 0.0 {
return cost;
}
}
0.0
}
fn missing_open_code_pricing(
model: &str,
provider: &str,
usage: TokenUsageRaw,
cost_usd: Option<f64>,
mode: CostMode,
pricing: Option<&PricingMap>,
) -> Option<String> {
if mode == CostMode::Display || cost_usd.is_some_and(|cost| cost > 0.0) {
return None;
}
missing_pricing_model_for_candidates(
model,
open_code_model_candidates(model, provider),
crate::total_usage_tokens(usage),
pricing,
)
}
fn open_code_model_candidates(model: &str, provider: &str) -> Vec<String> {
let resolved = resolve_open_code_model_name(model);
let normalized = normalize_open_code_model_name(&resolved);
let mut base = vec![resolved];
if normalized != base[0] {
base.push(normalized);
}
let mut candidates = base.clone();
if provider != "unknown" {
let provider = provider.replace('-', "_");
candidates.extend(base.into_iter().map(|model| format!("{provider}/{model}")));
}
candidates.dedup();
candidates
}
fn resolve_open_code_model_name(model: &str) -> String {
match model {
"gemini-3-pro-high" => "gemini-3-pro-preview".to_string(),
"k2p6" => "kimi-k2.6".to_string(),
_ => model.to_string(),
}
}
fn normalize_open_code_model_name(model: &str) -> String {
for family in ["claude-haiku-", "claude-opus-", "claude-sonnet-"] {
if let Some(rest) = model.strip_prefix(family) {
if let Some((major, minor_and_suffix)) = rest.split_once('.')
&& major.chars().all(|ch| ch.is_ascii_digit())
&& minor_and_suffix
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_digit())
{
return format!("{family}{major}-{minor_and_suffix}");
}
let mut chars = rest.chars();
if let (Some(major), Some(minor)) = (chars.next(), chars.next())
&& major.is_ascii_digit()
&& minor.is_ascii_digit()
{
return format!("{family}{major}-{minor}{}", chars.collect::<String>());
}
}
}
model.to_string()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
OpenCodeMessage, OpenCodeSessionAggregate, message_value_to_entry,
open_code_model_candidates, session_value_to_entry,
};
use crate::{LoadedEntry, PricingMap, cli::CostMode};
fn message(value: serde_json::Value) -> OpenCodeMessage {
serde_json::from_value(value).unwrap()
}
fn entry_snapshot(entry: &LoadedEntry) -> serde_json::Value {
json!({
"date": entry.date,
"timestamp": entry.timestamp.as_millis(),
"sessionId": entry.session_id.as_ref(),
"project": entry.project.as_ref(),
"projectPath": entry.project_path.as_ref(),
"cost": entry.cost,
"extraTotalTokens": entry.extra_total_tokens,
"model": entry.model.as_deref(),
"data": {
"sessionId": entry.data.session_id.as_deref(),
"timestamp": entry.data.timestamp,
"version": entry.data.version.as_deref(),
"message": {
"id": entry.data.message.id.as_deref(),
"model": entry.data.message.model.as_deref(),
"usage": {
"inputTokens": entry.data.message.usage.input_tokens,
"outputTokens": entry.data.message.usage.output_tokens,
"cacheCreationInputTokens": entry.data.message.usage.cache_creation_input_tokens,
"cacheReadInputTokens": entry.data.message.usage.cache_read_input_tokens,
},
},
"costUSD": entry.data.cost_usd,
},
})
}
#[test]
fn calculates_cost_when_opencode_stores_zero_cost() {
let mut pricing = PricingMap::default();
pricing.load_json(
r#"{
"gpt-test": {
"input_cost_per_token": 0.000001,
"output_cost_per_token": 0.000010,
"cache_read_input_token_cost": 0.0000001
}
}"#,
);
let entry = message_value_to_entry(
&message(json!({
"id": "message-a",
"sessionID": "session-a",
"providerID": "openai",
"modelID": "gpt-test",
"time": { "created": 0 },
"tokens": {
"input": 100,
"output": 10,
"cache": { "read": 50 }
},
"cost": 0
})),
None,
None,
None,
CostMode::Auto,
Some(&pricing),
)
.unwrap();
assert_eq!(entry.cost, 0.000205);
}
#[test]
fn keeps_positive_opencode_cost() {
let entry = message_value_to_entry(
&message(json!({
"id": "message-a",
"sessionID": "session-a",
"providerID": "openai",
"modelID": "gpt-test",
"time": { "created": 0 },
"tokens": {
"input": 100
},
"cost": 0.02
})),
None,
None,
None,
CostMode::Auto,
None,
)
.unwrap();
assert_eq!(entry.cost, 0.02);
}
#[test]
fn keeps_opencode_record_when_cache_field_is_not_an_object() {
let entry = message_value_to_entry(
&message(json!({
"id": "message-a",
"sessionID": "session-a",
"providerID": "openai",
"modelID": "gpt-test",
"time": { "created": 0 },
"tokens": {
"input": 100,
"output": 10,
"cache": 0
},
"cost": 0.02
})),
None,
None,
None,
CostMode::Auto,
None,
)
.unwrap();
assert_eq!(entry.data.message.usage.input_tokens, 100);
assert_eq!(entry.data.message.usage.output_tokens, 10);
assert_eq!(entry.data.message.usage.cache_creation_input_tokens, 0);
assert_eq!(entry.data.message.usage.cache_read_input_tokens, 0);
assert_eq!(entry.cost, 0.02);
}
#[test]
fn falls_back_to_total_tokens_when_opencode_token_parts_are_missing() {
let entry = message_value_to_entry(
&message(json!({
"id": "message-a",
"sessionID": "session-a",
"providerID": "openai",
"modelID": "gpt-test",
"time": { "created": 0 },
"tokens": {
"total": 123
}
})),
None,
None,
None,
CostMode::Auto,
None,
)
.unwrap();
assert_eq!(entry.data.message.usage.output_tokens, 123);
assert_eq!(entry.extra_total_tokens, 0);
}
#[test]
fn creates_open_code_provider_and_normalized_model_candidates() {
assert_eq!(
open_code_model_candidates("claude-sonnet-4.5", "github-copilot"),
vec![
"claude-sonnet-4.5",
"claude-sonnet-4-5",
"github_copilot/claude-sonnet-4.5",
"github_copilot/claude-sonnet-4-5",
]
);
}
#[test]
fn calculates_cost_for_k2p6_when_opencode_stores_zero_cost() {
let pricing = PricingMap::load_embedded();
let entry = message_value_to_entry(
&message(json!({
"id": "message-a",
"sessionID": "session-a",
"providerID": "kimi-for-coding",
"modelID": "k2p6",
"time": { "created": 0 },
"tokens": {
"input": 100,
"output": 10,
"cache": { "read": 50 }
},
"cost": 0
})),
None,
None,
None,
CostMode::Auto,
Some(&pricing),
)
.unwrap();
assert_eq!(entry.cost, 0.000143);
}
#[test]
fn snapshots_message_to_entry_variants_and_model_candidates() {
let tz = crate::parse_tz(Some("UTC"));
let mut pricing = PricingMap::default();
pricing.load_json(
r#"{
"github_copilot/claude-sonnet-4-5": {
"input_cost_per_token": 0.125,
"output_cost_per_token": 0.25,
"cache_read_input_token_cost": 0.0625
}
}"#,
);
let calculated = message_value_to_entry(
&message(json!({
"id": "message-a",
"sessionID": "session-a",
"providerID": "github-copilot",
"modelID": "claude-sonnet-4.5",
"time": { "created": 1767312000000i64 },
"tokens": {
"input": 100,
"output": 10,
"cache": { "read": 50, "write": 25 },
"total": 185
},
"cost": 0
})),
None,
None,
tz.as_ref(),
CostMode::Auto,
Some(&pricing),
)
.unwrap();
let display_cost = message_value_to_entry(
&message(json!({
"id": "message-b",
"providerID": "openai",
"modelID": "gpt-test",
"time": { "created": 0 },
"tokens": { "total": 123 },
"cost": 0.02
})),
None,
Some("explicit-session".to_string()),
tz.as_ref(),
CostMode::Display,
None,
)
.unwrap();
insta::assert_json_snapshot!(json!({
"calculated": entry_snapshot(&calculated),
"displayCost": entry_snapshot(&display_cost),
"candidates": {
"anthropic": open_code_model_candidates("claude-sonnet-4.5", "anthropic"),
"copilot": open_code_model_candidates("claude-sonnet-4.5", "github-copilot"),
"geminiAlias": open_code_model_candidates("gemini-3-pro-high", "google"),
"unknownProvider": open_code_model_candidates("gpt-test", "unknown"),
}
}));
}
fn deepseek_pricing(input: f64) -> PricingMap {
let mut pricing = PricingMap::default();
pricing.load_json(&format!(
"{{\"deepseek-v4-flash\":{{\"input_cost_per_token\":{input},\"output_cost_per_token\":0.00000028}}}}"
));
pricing
}
#[test]
fn keeps_epoch_for_display_but_omits_missing_message_timestamp_for_pricing() {
let pricing = deepseek_pricing(0.000009);
let entry = message_value_to_entry(
&message(json!({
"id": "message-a",
"sessionID": "session-a",
"providerID": "deepseek",
"modelID": "deepseek-v4-flash",
"tokens": { "input": 1_000_000 },
"cost": 0
})),
None,
None,
None,
CostMode::Calculate,
Some(&pricing),
)
.unwrap();
assert_eq!(entry.timestamp.as_millis(), 0);
assert_eq!(entry.cost, 9.0);
}
#[test]
fn prices_cumulative_session_aggregates_with_static_rates() {
let pricing = deepseek_pricing(0.00000014);
let created = crate::parse_ts_timestamp("2026-08-17T01:00:00Z")
.unwrap()
.as_millis();
let entry = session_value_to_entry(
OpenCodeSessionAggregate {
session_id: "session-a".to_string(),
created,
model: "deepseek-v4-flash".to_string(),
provider: "deepseek".to_string(),
input_tokens: 1_000_000,
output_tokens: 0,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: None,
},
None,
CostMode::Calculate,
Some(&pricing),
)
.unwrap();
assert_eq!(entry.timestamp.as_millis(), created);
assert_eq!(entry.cost, 0.14);
}
}