1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! Which model runs a piece of work, in the words a prompt or status line states it.
//!
//! Rho knows this in several shapes already: the conversation config, an agent
//! definition's model policy, an internal agent's selection, a finished run's
//! status. Every surface that names a model for a reader routes through this one
//! type, so the executor, its subagents, and the advisor all read the same form.
//!
//! The model id always leads. It is the part a reader can act on: it picks the
//! provider route, it is what `/model` takes back, and it matches what provider
//! documentation calls the model. The catalog name follows in brackets when a
//! catalog carries one, because a model can be newer than whatever is reading
//! the text, and a guessed name is worse than none.
//!
//! Named [`PromptModel`] rather than `ModelIdentity` so it is not confused with
//! the SDK's replay identity (`provider` / `api` / `model`).
use rho_providers::model::display_name::{model_display_name, model_reference_with_display_name};
use rho_sdk::model::ModelIdentity;
use crate::{
claude_runtime::models::CLAUDE_CODE_SOURCE_LABEL,
config::{Config, InternalAgentModelConfig, InternalAgentTarget},
subagent::RunStatus,
};
/// The model behind one piece of work, named for prompt and status text.
///
/// Values are complete: [`Self::describe`] reads only fields on `self` and the
/// process catalog-name cache. It does not consult ambient "last run" state.
///
/// The runtime axis travels with the model, mirroring `InternalAgentTarget` and
/// `AgentRuntimeSpec`: Claude Code resolves its own model names, so its label
/// cannot be described in Rho's provider vocabulary alone.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PromptModel {
/// A model Rho drives through one of its providers.
Rho { provider: String, model: String },
/// The Claude Code CLI.
///
/// `requested` is the `--model` value Rho passes through, or `None` when Rho
/// omits the flag and Claude Code chooses. `resolved` is the concrete id a
/// run reported, when one has. Config and bind paths leave `resolved` empty;
/// run status fills it from the init frame.
ClaudeCli {
requested: Option<String>,
resolved: Option<String>,
},
}
impl PromptModel {
/// The model the conversation itself runs on.
pub(crate) fn from_config(config: &Config) -> Self {
Self::Rho {
provider: config.provider.clone(),
model: config.model.clone(),
}
}
/// The model a live provider reports it is driving.
pub(crate) fn from_sdk_identity(identity: &ModelIdentity) -> Self {
Self::Rho {
provider: identity.provider.clone(),
model: identity.model.clone(),
}
}
/// The model an internal agent (advisor, session title, goal judge) runs on.
pub(crate) fn from_internal_agent(selection: &InternalAgentModelConfig) -> Self {
match &selection.target {
InternalAgentTarget::Rho(rho) => Self::Rho {
provider: rho.provider.clone(),
model: rho.model.clone(),
},
InternalAgentTarget::ClaudeCli { model } => Self::ClaudeCli {
requested: model.clone(),
resolved: None,
},
}
}
/// The model a finished or in-flight run recorded on its status.
///
/// Returns `None` when the status has no provider/model pair for a Rho run.
/// Claude runs always yield a value: even with nothing pinned and nothing
/// resolved yet, the label still says Claude Code chooses.
pub(crate) fn from_run_status(status: &RunStatus) -> Option<Self> {
use crate::agent::AgentRuntime;
match status.runtime {
Some(AgentRuntime::ClaudeCli) => Some(Self::ClaudeCli {
requested: status
.model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
.map(str::to_string),
resolved: status
.claude_model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
.map(str::to_string),
}),
Some(AgentRuntime::Rho) | None => Some(Self::Rho {
provider: status
.provider
.as_deref()
.map(str::trim)
.filter(|provider| !provider.is_empty())
.map(str::to_string)?,
model: status
.model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
.map(str::to_string)?,
}),
}
}
/// How the identity reads in prompt or status text.
///
/// Rho models read as `provider/model (Catalog Name)`. Claude Code models
/// read as `claude-code/<--model value>`, plus what a run resolved when that
/// is carried on the value.
///
/// Always one line; see [`one_line`]. Catalog names come from the models.dev
/// snapshot interactive startup hydrates before the system prompt is built;
/// mid-session switch notices read the same cache.
pub(crate) fn describe(&self) -> String {
one_line(match self {
Self::Rho { provider, model } => model_reference_with_display_name(provider, model),
Self::ClaudeCli {
requested,
resolved,
} => describe_claude_cli(requested.as_deref(), resolved.as_deref()),
})
}
}
/// Replaces control characters with spaces.
///
/// Every part of a description comes from outside Rho: provider and model ids
/// from config, catalog names from the models.dev download. Callers write one
/// prompt line or one bracketed notice around this text, and a newline in any
/// part would turn the rest into a line of its own that the executor reads as
/// instructions.
fn one_line(text: String) -> String {
if !text.contains(char::is_control) {
return text;
}
text.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect()
}
/// Provider whose catalog names Claude Code's models.
///
/// Claude Code runs Anthropic models whatever it bills against, so its resolved
/// ids are looked up under Anthropic even though `claude-code` is what Rho
/// shows as the source.
const CLAUDE_CATALOG_PROVIDER: &str = "anthropic";
fn describe_claude_cli(requested: Option<&str>, resolved: Option<&str>) -> String {
match (requested, resolved) {
// A pinned id that is also the resolved id needs no resolution clause.
(Some(requested), Some(resolved)) if requested == resolved => {
claude_reference_with_name(requested)
}
(Some(requested), None) => claude_reference_with_name(requested),
// Requested alias (or other pointer) plus what the run bound.
(Some(requested), Some(resolved)) => format!(
"{}, ran as {}",
rho_providers::provider::model_reference(CLAUDE_CODE_SOURCE_LABEL, requested),
claude_model_with_name(resolved),
),
(None, Some(resolved)) => format!(
"{CLAUDE_CODE_SOURCE_LABEL} (no model pinned; ran as {})",
claude_model_with_name(resolved),
),
(None, None) => {
format!("{CLAUDE_CODE_SOURCE_LABEL} (no model pinned; Claude Code chooses)")
}
}
}
/// `claude-code/<model>` plus the catalog name when one is known.
fn claude_reference_with_name(model: &str) -> String {
let reference = rho_providers::provider::model_reference(CLAUDE_CODE_SOURCE_LABEL, model);
match model_display_name(CLAUDE_CATALOG_PROVIDER, model) {
Some(name) => format!("{reference} ({name})"),
None => reference,
}
}
/// A bare Claude model id plus its catalog name, for use inside a clause that
/// already named the source.
fn claude_model_with_name(model: &str) -> String {
match model_display_name(CLAUDE_CATALOG_PROVIDER, model) {
Some(name) => format!("{model} ({name})"),
None => model.to_string(),
}
}
#[cfg(test)]
#[path = "model_identity_tests.rs"]
mod tests;