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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
//! Process-local Claude settings for `router with`.
//!
//! Split from `with_command.rs` to stay inside the repository's per-file line
//! limit.
use std::fs;
use std::path::Path;
use std::process::Command;
use serde_json::{Value, json};
use super::AnyError;
use crate::clients::{ClientKind, RouterModel};
pub(super) struct ClaudeModelSelection {
pub model: String,
pub reason: String,
}
/// A built-in Claude family is unavailable when this token has no Anthropic
/// model in its live catalog. Concrete IDs are never classified by spelling;
/// their exact authenticated catalog row decides ownership.
pub(super) fn unavailable_native_claude_model<'a>(
model: &'a str,
models: &[RouterModel],
) -> Option<&'a str> {
let family = model
.trim()
.split_once('[')
.map_or_else(|| model.trim(), |(family, _)| family)
.to_ascii_lowercase();
let native = matches!(family.as_str(), "opus" | "sonnet" | "haiku");
let has_anthropic = crate::clients::usable_models(ClientKind::ClaudeCode, models)
.iter()
.any(|candidate| candidate.owned_by == crate::clients::ANTHROPIC_MODEL_OWNER);
(native && !has_anthropic).then_some(model)
}
pub(super) fn validate_claude_model_selection(
selection: ClaudeModelSelection,
models: &[RouterModel],
) -> Result<Option<String>, AnyError> {
let model = selection.model.trim();
let catalog_model = model
.split_once('[')
.map_or(model, |(model, _)| model)
.trim();
let has_anthropic = crate::clients::usable_models(ClientKind::ClaudeCode, models)
.iter()
.any(|candidate| candidate.owned_by == crate::clients::ANTHROPIC_MODEL_OWNER);
// Claude's saved `default` is semantic rather than an upstream ID. On a
// z.ai-only catalog, Router's main/subagent fallback is how that semantic
// choice remains usable.
if model.eq_ignore_ascii_case("default") && !has_anthropic {
return Ok(None);
}
if let Some(unavailable) = unavailable_native_claude_model(model, models) {
return Err(format!(
"Claude model `{unavailable}` requires an Anthropic provider, but this client's \
authorized live catalog contains none; choose one of the visible exact models with \
/model, configure Anthropic, or clear the stale model selection"
)
.into());
}
let native_family = ["default", "opus", "sonnet", "haiku"]
.iter()
.any(|family| catalog_model.eq_ignore_ascii_case(family));
let exact = crate::clients::usable_models(ClientKind::ClaudeCode, models)
.iter()
.any(|candidate| candidate.id == catalog_model)
&& !model.ends_with("[1m]");
let context_variant = crate::clients::claude_context_variant_is_authorized(models, model);
if (native_family && has_anthropic) || exact || context_variant {
return Ok(Some(selection.reason));
}
Err(format!(
"Claude model `{model}` is not in this client's authorized live catalog; choose one of \
the visible exact models with /model or clear the stale model selection"
)
.into())
}
/// Build the process-local Claude settings a Router-directed launch needs.
///
/// Two things live here: the presentation default that keeps a completed
/// thinking trace visible (issue #560), and the `/model` picker rows for every
/// exact model this client's authorized live catalog can serve (issue #621).
///
/// Claude's own gateway discovery keeps only `claude`/`anthropic`-shaped IDs,
/// so the picker is filled from the Router catalog instead: every usable row,
/// Anthropic's exact IDs included when Anthropic is authorized. A row is
/// `{label, model}`, which Claude Code accepts on its own; `behavesAs` is
/// optional there and is added only when Router holds verified capability
/// metadata for that exact ID. Requiring it made a z.ai-only catalog — whose
/// rows never carry it — refuse to launch at all (issue #620). Nothing is
/// inferred from a model's spelling.
///
/// This is a command-line setting for this process only: neither the
/// Router-owned profile nor the user's profile is rewritten, so the next
/// launch lists whatever the authorized catalog holds then.
pub(super) fn append_claude_model_picker(
command: &mut Command,
models: &[RouterModel],
) -> Result<(), AnyError> {
let (options, has_anthropic) = claude_model_picker_options(models);
// Keeping a completed thinking trace on screen is a presentation default,
// and the Router-owned profile starts empty by design (issue #536) — so it
// carries none of the preferences the user's normal Claude profile has.
// Genuine thinking was therefore visible while a response streamed and
// collapsed to `Thought for Ns` the moment it finished, only under a bare
// `router with claude` (issue #560). This restores the presentation the
// same user gets from their own profile; it changes nothing about the
// protocol, so a response that carries no thinking still shows none.
//
// Written unconditionally, not only when the picker has rows: a deployment
// whose catalog needs no extra picker options is exactly as affected.
let mut settings = serde_json::Map::new();
settings.insert("verbose".into(), json!(true));
if !options.is_empty() {
settings.insert(
"modelPicker".into(),
json!({
"options": options,
// Native family rows are usable only when this exact client's
// authorized live catalog contains Anthropic. In a z.ai-only
// catalog, retaining them sends the next prompt to a provider
// the token cannot reach (issue #577).
"replaceBuiltInOptions": !has_anthropic,
}),
);
}
// Router's own `--settings` goes on before the user's forwarded arguments,
// so an explicit `--settings` or `--verbose` of their own still wins: the
// last occurrence is the one Claude applies.
command
.arg("--settings")
.arg(serde_json::to_string(&Value::Object(settings))?);
Ok(())
}
/// The picker rows for `models`, and whether Anthropic is among them.
///
/// Built-in family names are left to Claude's own rows. Duplicate catalog rows
/// for one exact ID collapse into one picker row; their capability metadata
/// is kept only when every row agrees, since a disagreement is not evidence.
fn claude_model_picker_options(models: &[RouterModel]) -> (Vec<Value>, bool) {
const BUILT_INS: [&str; 4] = ["default", "opus", "sonnet", "haiku"];
let usable = crate::clients::usable_models(ClientKind::ClaudeCode, models);
let has_anthropic = usable
.iter()
.any(|model| model.owned_by == crate::clients::ANTHROPIC_MODEL_OWNER);
let mut candidates = usable
.into_iter()
.filter(|model| {
let folded = model.id.trim().to_ascii_lowercase();
!folded.is_empty() && !BUILT_INS.contains(&folded.as_str())
})
.collect::<Vec<_>>();
// Anthropic's own models first, then every other provider; each by ID.
candidates.sort_by(|left, right| {
let other = |model: &RouterModel| model.owned_by != crate::clients::ANTHROPIC_MODEL_OWNER;
other(left)
.cmp(&other(right))
.then_with(|| left.id.cmp(&right.id))
});
let mut rows: Vec<(String, Option<String>)> = Vec::with_capacity(candidates.len());
let unavailable = candidates
.iter()
.filter(|model| !model.is_servable())
.map(|model| model.id.clone())
.collect::<Vec<_>>();
for model in candidates {
let behaves_as = model
.client_capabilities
.claude
.as_ref()
.filter(|capability| {
!capability.behaves_as.trim().is_empty() && !capability.source.trim().is_empty()
})
.map(|capability| capability.behaves_as.clone());
match rows.iter_mut().find(|(id, _)| id == &model.id) {
Some((_, existing)) if *existing != behaves_as => *existing = None,
Some(_) => {}
None => rows.push((model.id.clone(), behaves_as)),
}
}
let options = rows
.into_iter()
.map(|(id, behaves_as)| {
let mut row = serde_json::Map::new();
// The row stays selectable — z.ai still lists it — but the label
// says Router cannot serve it right now (issue #657).
let label = if unavailable.contains(&id) {
format!("{id} (unavailable)")
} else {
id.clone()
};
row.insert("label".into(), json!(label));
row.insert("model".into(), json!(id));
if let Some(behaves_as) = behaves_as {
row.insert("behavesAs".into(), json!(behaves_as));
}
Value::Object(row)
})
.collect();
(options, has_anthropic)
}
/// A warning for a launch whose model Router cannot serve right now, naming a
/// servable alternative (issue #657).
///
/// The launch is not refused: the request still reaches the provider, which
/// may have been recharged, and is answered with the provider's reason if not.
/// What the user must not get is a silent session on a model that cannot
/// answer.
pub(super) fn claude_unavailable_model_warning(
model: Option<&str>,
models: &[RouterModel],
) -> Option<String> {
let model = model?.trim();
let reason = models
.iter()
.filter(|candidate| candidate.id == model)
.find_map(|candidate| candidate.router_unavailable_reason.as_deref())?;
let alternative = crate::clients::usable_models(ClientKind::ClaudeCode, models)
.into_iter()
.find(crate::clients::RouterModel::is_servable)
.map(|candidate| candidate.id);
let advice = alternative.map_or_else(
|| "no other model in this client's catalog is currently servable".to_string(),
|alternative| {
format!("choose another model with /model or --model, for example `{alternative}`")
},
);
Some(format!(
"warning: Claude model `{model}` cannot be served right now: {reason}; {advice}"
))
}
/// The Claude model the client itself has saved as its default, if any.
///
/// The returned exact model is validated against the authorized live catalog
/// before its human-readable reason is used in Router's note. A wrapper-set
/// `ANTHROPIC_MODEL` outranks the client's own selection for every new session,
/// so pinning over a valid saved `/model` choice silently overrode the
/// documented way to pick a model (issue #563). The environment half of that
/// rule is resolved by the caller.
///
/// The profile consulted is the one this launch actually hands the client — the
/// Router-owned one by default, the user's own under `--extend-global-config`.
/// The Router-owned profile is `CLAUDE_CONFIG_DIR` itself, so Claude keeps its
/// user settings, and a `/model` choice, directly in it. Reading the
/// `.claude/settings.json` a `$HOME`-rooted layout would use saw no choice at
/// all: the pin overrode it, and a choice the catalog no longer authorizes was
/// never refused (issue #630).
pub(super) fn claude_saved_model_selection(
root: &Path,
extends_user_configuration: bool,
user_settings: Option<&Path>,
) -> Option<ClaudeModelSelection> {
// Under `--extend-global-config` the client reads the user's real profile.
// The caller resolved the file Claude will actually open from its own
// environment, so nothing here reads the process environment and a test
// cannot inherit a real profile (#613).
let settings = if extends_user_configuration {
user_settings?.to_path_buf()
} else {
root.join("settings.json")
};
let saved = fs::read_to_string(settings).ok()?;
// A profile Claude has not written yet, or one hand-edited into invalid
// JSON, is not a selection. Failing open here would pin over a choice; the
// safe reading of "cannot tell" is to leave the pin to the catalog.
let document: serde_json::Value = serde_json::from_str(&saved).ok()?;
let model = document.get("model")?.as_str()?.trim();
(!model.is_empty()).then(|| ClaudeModelSelection {
model: model.to_string(),
reason: format!("the model saved in your Claude profile ({model})"),
})
}