@team-agent/installer 0.5.101 → 0.5.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/models.rs +92 -226
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/main_preserved.rs +0 -74
- package/crates/team-agent/src/compiler/tests.rs +20 -23
- package/crates/team-agent/src/compiler.rs +49 -65
- package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +16 -48
- package/crates/team-agent/src/leader/start.rs +1 -14
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +37 -161
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +0 -28
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +11 -9
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +8 -401
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +22 -8
- package/crates/team-agent/src/mcp_server/helpers.rs +0 -16
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +2 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +1 -127
- package/crates/team-agent/src/mcp_server/mod.rs +7 -12
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -132
- package/crates/team-agent/src/mcp_server/tests/send.rs +1 -93
- package/crates/team-agent/src/mcp_server/tests/tools.rs +0 -61
- package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -51
- package/crates/team-agent/src/mcp_server/tests.rs +4 -200
- package/crates/team-agent/src/mcp_server/tools.rs +5 -396
- package/crates/team-agent/src/mcp_server/types.rs +3 -36
- package/crates/team-agent/src/mcp_server/wire.rs +2 -223
- package/crates/team-agent/src/messaging/leader_receiver.rs +1 -1
- package/crates/team-agent/src/messaging/mod.rs +2 -2
- package/crates/team-agent/src/messaging/results.rs +11 -42
- package/crates/team-agent/src/messaging/scheduler.rs +2 -205
- package/crates/team-agent/src/messaging/tests/basic.rs +0 -31
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +0 -73
- package/crates/team-agent/src/messaging/tests/runtime.rs +0 -26
- package/crates/team-agent/src/messaging/types.rs +0 -20
- package/crates/team-agent/src/model/spec.rs +14 -18
- package/crates/team-agent/src/provider/mod.rs +1 -0
- package/crates/team-agent/src/provider/model_catalog.rs +650 -0
- package/crates/team-agent/src/state/repository/tests.rs +1 -16
- package/crates/team-agent/src/state/repository.rs +1 -33
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +5 -5
- package/skills/team-agent/command-coverage.json +7 -0
- package/skills/team-agent/references/team-agent-operator.md +24 -116
- package/skills/team-agent/references/team-in-team.md +4 -19
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +0 -484
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
//! Provider model discovery, bounded native catalog queries, and shared fuzzy search.
|
|
2
|
+
use std::collections::{HashMap, HashSet};
|
|
3
|
+
use std::ffi::OsString;
|
|
4
|
+
use std::io::{Read, Write};
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
|
+
use std::process::{Child, Command, ExitStatus, Stdio};
|
|
7
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
8
|
+
use std::sync::mpsc;
|
|
9
|
+
use std::time::{Duration, Instant};
|
|
10
|
+
|
|
11
|
+
use crate::model::enums::Provider;
|
|
12
|
+
|
|
13
|
+
const CATALOG_TIMEOUT: Duration = Duration::from_secs(10);
|
|
14
|
+
const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
|
|
15
|
+
const CLAUDE_REQUEST_ID: &str = "team-agent-model-catalog";
|
|
16
|
+
static TEMP_DIR_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
17
|
+
|
|
18
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
19
|
+
pub struct ModelRecord {
|
|
20
|
+
pub provider: String,
|
|
21
|
+
pub vendor: String,
|
|
22
|
+
pub id: String,
|
|
23
|
+
pub display_name: String,
|
|
24
|
+
pub default: Option<bool>,
|
|
25
|
+
pub aliases: Vec<String>,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
29
|
+
pub enum CatalogError {
|
|
30
|
+
UnsupportedProvider,
|
|
31
|
+
ExecutableUnavailable,
|
|
32
|
+
Timeout,
|
|
33
|
+
CommandFailed,
|
|
34
|
+
OutputUnavailable,
|
|
35
|
+
OutputReadFailed,
|
|
36
|
+
OutputTooLarge,
|
|
37
|
+
InputWriteFailed,
|
|
38
|
+
InvalidUtf8,
|
|
39
|
+
EmptyInput,
|
|
40
|
+
Malformed,
|
|
41
|
+
MalformedPiHeader,
|
|
42
|
+
MalformedPiRow(usize),
|
|
43
|
+
DuplicateIdentity(String),
|
|
44
|
+
Empty,
|
|
45
|
+
UnsupportedSchema,
|
|
46
|
+
InvalidProtocol,
|
|
47
|
+
TemporaryDirectoryUnavailable,
|
|
48
|
+
CommandCouldNotBeObserved,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
impl CatalogError {
|
|
52
|
+
pub fn message(&self, provider: &str) -> String {
|
|
53
|
+
let name = display_provider(provider);
|
|
54
|
+
match self {
|
|
55
|
+
Self::UnsupportedProvider => format!("unsupported model provider {provider:?}"),
|
|
56
|
+
Self::ExecutableUnavailable => format!("{name} executable is unavailable on PATH"),
|
|
57
|
+
Self::Timeout => format!("{name} model catalog command timed out"),
|
|
58
|
+
Self::CommandFailed => format!("{name} model catalog command failed"),
|
|
59
|
+
Self::OutputUnavailable => format!("{name} model catalog output unavailable"),
|
|
60
|
+
Self::OutputReadFailed => format!("{name} model catalog could not be read"),
|
|
61
|
+
Self::OutputTooLarge => format!("{name} model catalog exceeds the bounded output limit"),
|
|
62
|
+
Self::InputWriteFailed => format!("{name} model catalog request could not be written"),
|
|
63
|
+
Self::InvalidUtf8 => format!("{name} model catalog is not valid UTF-8"),
|
|
64
|
+
Self::EmptyInput => "Pi model catalog is empty".into(),
|
|
65
|
+
Self::Malformed => format!("{name} model catalog is malformed"),
|
|
66
|
+
Self::MalformedPiHeader => "Pi model catalog has an unexpected header".into(),
|
|
67
|
+
Self::MalformedPiRow(row) => format!("Pi model catalog row {row} is malformed"),
|
|
68
|
+
Self::DuplicateIdentity(_) => format!("{name} model catalog contains duplicate model ids"),
|
|
69
|
+
Self::Empty => format!("{name} model catalog is empty"),
|
|
70
|
+
Self::UnsupportedSchema => format!("{name} model catalog uses an unsupported schema"),
|
|
71
|
+
Self::InvalidProtocol => format!("{name} model catalog returned an invalid protocol response"),
|
|
72
|
+
Self::TemporaryDirectoryUnavailable => "Claude model catalog could not create an isolated temporary directory".into(),
|
|
73
|
+
Self::CommandCouldNotBeObserved => format!("{name} model catalog command could not be observed"),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub fn action(&self, provider: &str) -> String {
|
|
78
|
+
let executable = match provider {
|
|
79
|
+
"pi" => "pi",
|
|
80
|
+
"cursor_agent" => "agent",
|
|
81
|
+
"codex" => "codex",
|
|
82
|
+
"claude" | "claude_code" => "claude",
|
|
83
|
+
_ => "provider CLI",
|
|
84
|
+
};
|
|
85
|
+
match self {
|
|
86
|
+
Self::UnsupportedProvider => "choose a supported provider: pi, cursor_agent, codex, claude, or claude_code".into(),
|
|
87
|
+
Self::ExecutableUnavailable => format!("install or repair the PATH-first `{executable}` executable, then retry `team-agent models --provider {provider}`"),
|
|
88
|
+
Self::UnsupportedSchema => format!("upgrade `{executable}` to a version that supports its model catalog protocol, then retry `team-agent models --provider {provider}`"),
|
|
89
|
+
Self::CommandFailed => format!("run `{executable}` directly to check catalog/account access, then retry `team-agent models --provider {provider}`"),
|
|
90
|
+
_ => format!("check the native `{executable}` catalog command, then retry `team-agent models --provider {provider}`"),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
pub(crate) fn pi_legacy_message(&self) -> String {
|
|
95
|
+
match self {
|
|
96
|
+
Self::ExecutableUnavailable => "Pi executable is unavailable on PATH".into(),
|
|
97
|
+
Self::Timeout => "Pi model catalog command timed out".into(),
|
|
98
|
+
Self::CommandFailed => "Pi model catalog command failed".into(),
|
|
99
|
+
Self::OutputUnavailable => "Pi model catalog output unavailable".into(),
|
|
100
|
+
Self::OutputReadFailed => "Pi model catalog could not be read".into(),
|
|
101
|
+
Self::OutputTooLarge => "Pi model catalog exceeds the bounded output limit".into(),
|
|
102
|
+
Self::InputWriteFailed => "Pi model catalog request could not be written".into(),
|
|
103
|
+
Self::InvalidUtf8 => "Pi model catalog is not UTF-8".into(),
|
|
104
|
+
Self::EmptyInput => "Pi model catalog is empty".into(),
|
|
105
|
+
Self::MalformedPiHeader => "Pi model catalog has an unexpected header".into(),
|
|
106
|
+
Self::MalformedPiRow(row) => format!("Pi model catalog row {row} is malformed"),
|
|
107
|
+
Self::DuplicateIdentity(id) => format!("Pi model catalog contains duplicate exact id {id:?}"),
|
|
108
|
+
Self::Empty => "Pi model catalog contains no models".into(),
|
|
109
|
+
Self::CommandCouldNotBeObserved => "Pi model catalog command could not be observed".into(),
|
|
110
|
+
_ => "Pi model catalog is invalid".into(),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn display_provider(provider: &str) -> &str {
|
|
116
|
+
match provider {
|
|
117
|
+
"pi" => "Pi",
|
|
118
|
+
"cursor_agent" => "Cursor",
|
|
119
|
+
"codex" => "Codex",
|
|
120
|
+
"claude" | "claude_code" => "Claude",
|
|
121
|
+
_ => provider,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Only the documented catalog names are accepted; launch aliases stay unchanged.
|
|
126
|
+
pub fn parse_catalog_provider(name: &str) -> Option<Provider> {
|
|
127
|
+
match name {
|
|
128
|
+
"pi" => Some(Provider::Pi),
|
|
129
|
+
"cursor_agent" => Some(Provider::CursorAgent),
|
|
130
|
+
"codex" => Some(Provider::Codex),
|
|
131
|
+
"claude" => Some(Provider::Claude),
|
|
132
|
+
"claude_code" => Some(Provider::ClaudeCode),
|
|
133
|
+
_ => None,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn provider_wire_name(provider: Provider) -> &'static str {
|
|
138
|
+
match provider {
|
|
139
|
+
Provider::Pi => "pi",
|
|
140
|
+
Provider::CursorAgent => "cursor_agent",
|
|
141
|
+
Provider::Codex => "codex",
|
|
142
|
+
Provider::Claude => "claude",
|
|
143
|
+
Provider::ClaudeCode => "claude_code",
|
|
144
|
+
Provider::Copilot => "copilot",
|
|
145
|
+
Provider::GeminiCli => "gemini_cli",
|
|
146
|
+
Provider::Grok => "grok",
|
|
147
|
+
Provider::Fake => "fake",
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[derive(Clone, Copy)]
|
|
152
|
+
enum CatalogSource {
|
|
153
|
+
Pi,
|
|
154
|
+
CursorAgent,
|
|
155
|
+
Codex,
|
|
156
|
+
Claude,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Exhaustive by design: a new Provider variant must make an explicit catalog decision.
|
|
160
|
+
fn catalog_source(provider: Provider) -> Option<CatalogSource> {
|
|
161
|
+
match provider {
|
|
162
|
+
Provider::Pi => Some(CatalogSource::Pi),
|
|
163
|
+
Provider::CursorAgent => Some(CatalogSource::CursorAgent),
|
|
164
|
+
Provider::Codex => Some(CatalogSource::Codex),
|
|
165
|
+
Provider::Claude | Provider::ClaudeCode => Some(CatalogSource::Claude),
|
|
166
|
+
Provider::Copilot | Provider::GeminiCli | Provider::Grok | Provider::Fake => None,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
pub fn discover_model_catalog(provider_name: &str) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
171
|
+
let provider = parse_catalog_provider(provider_name).ok_or(CatalogError::UnsupportedProvider)?;
|
|
172
|
+
let source = catalog_source(provider).ok_or(CatalogError::UnsupportedProvider)?;
|
|
173
|
+
let requested_provider = provider_wire_name(provider);
|
|
174
|
+
let records = match source {
|
|
175
|
+
CatalogSource::Pi => {
|
|
176
|
+
let bytes = run_native("pi", &["--list-models"], None, None, &[], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
|
|
177
|
+
parse_pi_catalog(&bytes)?
|
|
178
|
+
}
|
|
179
|
+
CatalogSource::CursorAgent => {
|
|
180
|
+
let bytes = run_native("agent", &["--list-models"], None, None, &[], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
|
|
181
|
+
parse_cursor_catalog(&bytes)?
|
|
182
|
+
}
|
|
183
|
+
CatalogSource::Codex => {
|
|
184
|
+
let bytes = run_native("codex", &["debug", "models"], None, None, &[], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
|
|
185
|
+
parse_codex_catalog(&bytes, requested_provider)?
|
|
186
|
+
}
|
|
187
|
+
CatalogSource::Claude => discover_claude_catalog(requested_provider)?,
|
|
188
|
+
};
|
|
189
|
+
Ok(records)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// One shared token-AND/field-OR, case-insensitive substring matcher.
|
|
193
|
+
pub fn model_matches(record: &ModelRecord, query: &str) -> bool {
|
|
194
|
+
let fields = std::iter::once(record.provider.as_str())
|
|
195
|
+
.chain(std::iter::once(record.vendor.as_str()))
|
|
196
|
+
.chain(std::iter::once(record.id.as_str()))
|
|
197
|
+
.chain(std::iter::once(record.display_name.as_str()))
|
|
198
|
+
.chain(record.aliases.iter().map(String::as_str))
|
|
199
|
+
.map(str::to_lowercase)
|
|
200
|
+
.collect::<Vec<_>>();
|
|
201
|
+
query
|
|
202
|
+
.split_whitespace()
|
|
203
|
+
.map(str::to_lowercase)
|
|
204
|
+
.all(|token| fields.iter().any(|field| field.contains(&token)))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
pub(crate) fn parse_pi_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
208
|
+
let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::InvalidUtf8)?;
|
|
209
|
+
let mut lines = text.lines();
|
|
210
|
+
let header = lines.next().ok_or(CatalogError::EmptyInput)?;
|
|
211
|
+
let columns = header.split_whitespace().collect::<Vec<_>>();
|
|
212
|
+
if columns.get(0) != Some(&"provider") || columns.get(1) != Some(&"model") {
|
|
213
|
+
return Err(CatalogError::MalformedPiHeader);
|
|
214
|
+
}
|
|
215
|
+
let mut seen = HashSet::new();
|
|
216
|
+
let mut records = Vec::new();
|
|
217
|
+
for (index, line) in lines.enumerate() {
|
|
218
|
+
if line.trim().is_empty() {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
let malformed = || CatalogError::MalformedPiRow(index + 2);
|
|
222
|
+
let (vendor, rest) = line.trim().split_once(char::is_whitespace).ok_or_else(malformed)?;
|
|
223
|
+
let mut id = rest.trim();
|
|
224
|
+
for _ in 2..columns.len() {
|
|
225
|
+
id = id.rsplit_once(char::is_whitespace).ok_or_else(malformed)?.0.trim_end();
|
|
226
|
+
}
|
|
227
|
+
if id.is_empty() {
|
|
228
|
+
return Err(malformed());
|
|
229
|
+
}
|
|
230
|
+
let exact = format!("{vendor}/{id}");
|
|
231
|
+
if !seen.insert(exact.clone()) {
|
|
232
|
+
return Err(CatalogError::DuplicateIdentity(exact));
|
|
233
|
+
}
|
|
234
|
+
records.push(ModelRecord {
|
|
235
|
+
provider: "pi".into(), vendor: vendor.into(), display_name: exact.clone(), id: exact,
|
|
236
|
+
default: None, aliases: Vec::new(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if records.is_empty() { return Err(CatalogError::Empty); }
|
|
240
|
+
Ok(records)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
pub(crate) fn parse_cursor_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
244
|
+
let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::InvalidUtf8)?;
|
|
245
|
+
let mut records: Vec<ModelRecord> = Vec::new();
|
|
246
|
+
for line in text.lines() {
|
|
247
|
+
let line = line.trim();
|
|
248
|
+
if line.is_empty() || line.trim_end_matches(':').eq_ignore_ascii_case("available models") { continue; }
|
|
249
|
+
let Some((id, display)) = line.split_once(" - ") else { return Err(CatalogError::Malformed); };
|
|
250
|
+
let id = id.trim();
|
|
251
|
+
if id.is_empty() || id.chars().any(char::is_whitespace) || display.trim().is_empty() { return Err(CatalogError::Malformed); }
|
|
252
|
+
if records.iter().any(|record| record.id == id) { return Err(CatalogError::DuplicateIdentity(id.into())); }
|
|
253
|
+
let display = display.trim();
|
|
254
|
+
let default = display.ends_with("(default)");
|
|
255
|
+
let display_name = display.strip_suffix("(default)").map_or(display, str::trim).to_string();
|
|
256
|
+
let vendor = id.split_once('-').map_or("cursor", |(vendor, _)| vendor).to_string();
|
|
257
|
+
records.push(ModelRecord { provider: "cursor_agent".into(), vendor, id: id.into(), display_name, default: Some(default), aliases: Vec::new() });
|
|
258
|
+
}
|
|
259
|
+
if records.is_empty() { return Err(CatalogError::Empty); }
|
|
260
|
+
Ok(records)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fn parse_codex_catalog(bytes: &[u8], provider: &str) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
264
|
+
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| CatalogError::Malformed)?;
|
|
265
|
+
let rows = value.get("models").and_then(serde_json::Value::as_array).ok_or(CatalogError::UnsupportedSchema)?;
|
|
266
|
+
if rows.is_empty() { return Err(CatalogError::Empty); }
|
|
267
|
+
let mut seen = HashSet::new();
|
|
268
|
+
let mut visible = Vec::new();
|
|
269
|
+
for row in rows {
|
|
270
|
+
let row = row.as_object().ok_or(CatalogError::Malformed)?;
|
|
271
|
+
let slug = required_string(row.get("slug"))?;
|
|
272
|
+
let display = required_string(row.get("display_name"))?;
|
|
273
|
+
let visibility = required_string(row.get("visibility"))?;
|
|
274
|
+
validate_identity(slug)?;
|
|
275
|
+
validate_display(display)?;
|
|
276
|
+
if !seen.insert(slug.to_string()) { return Err(CatalogError::DuplicateIdentity(slug.into())); }
|
|
277
|
+
match visibility {
|
|
278
|
+
"list" => visible.push(ModelRecord { provider: provider.into(), vendor: "openai".into(), id: slug.into(), display_name: display.into(), default: None, aliases: Vec::new() }),
|
|
279
|
+
"hide" | "none" => {},
|
|
280
|
+
_ => return Err(CatalogError::UnsupportedSchema),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if visible.is_empty() { return Err(CatalogError::Empty); }
|
|
284
|
+
Ok(visible)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fn discover_claude_catalog(provider: &str) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
288
|
+
let cwd = OwnedTempDir::create()?;
|
|
289
|
+
let args = [
|
|
290
|
+
"--print", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
|
|
291
|
+
"--no-session-persistence", "--strict-mcp-config", "--mcp-config", r#"{"mcpServers":{}}"#,
|
|
292
|
+
"--setting-sources", "", "--settings", r#"{"disableAllHooks":true}"#, "--tools", "", "--no-chrome",
|
|
293
|
+
];
|
|
294
|
+
let request = serde_json::json!({"type":"control_request","request_id":CLAUDE_REQUEST_ID,"request":{"subtype":"initialize","hooks":{},"agents":{},"skills":[]}});
|
|
295
|
+
let mut input = serde_json::to_vec(&request).map_err(|_| CatalogError::InvalidProtocol)?;
|
|
296
|
+
input.push(b'\n');
|
|
297
|
+
let bytes = run_native("claude", &args, Some(&input), Some(&cwd.0), &["CLAUDECODE"], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
|
|
298
|
+
parse_claude_catalog(&bytes, provider)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn parse_claude_catalog(bytes: &[u8], provider: &str) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
302
|
+
let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::InvalidUtf8)?;
|
|
303
|
+
let mut response = None;
|
|
304
|
+
for line in text.lines() {
|
|
305
|
+
if line.is_empty() { return Err(CatalogError::InvalidProtocol); }
|
|
306
|
+
let frame: serde_json::Value = serde_json::from_str(line).map_err(|_| CatalogError::InvalidProtocol)?;
|
|
307
|
+
let frame_type = frame
|
|
308
|
+
.get("type")
|
|
309
|
+
.and_then(serde_json::Value::as_str)
|
|
310
|
+
.filter(|frame_type| !frame_type.is_empty())
|
|
311
|
+
.ok_or(CatalogError::InvalidProtocol)?;
|
|
312
|
+
if frame_type != "control_response" {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if response.is_some() {
|
|
316
|
+
return Err(CatalogError::InvalidProtocol);
|
|
317
|
+
}
|
|
318
|
+
let result = frame.get("response").ok_or(CatalogError::UnsupportedSchema)?;
|
|
319
|
+
let outer_request_id = frame.get("request_id").and_then(serde_json::Value::as_str);
|
|
320
|
+
let inner_request_id = result.get("request_id").and_then(serde_json::Value::as_str);
|
|
321
|
+
if (outer_request_id != Some(CLAUDE_REQUEST_ID) && inner_request_id != Some(CLAUDE_REQUEST_ID))
|
|
322
|
+
|| frame.get("request_id").is_some_and(|id| id.as_str() != Some(CLAUDE_REQUEST_ID))
|
|
323
|
+
|| result.get("request_id").is_some_and(|id| id.as_str() != Some(CLAUDE_REQUEST_ID))
|
|
324
|
+
{
|
|
325
|
+
return Err(CatalogError::InvalidProtocol);
|
|
326
|
+
}
|
|
327
|
+
if result.get("subtype").and_then(serde_json::Value::as_str) != Some("success") { return Err(CatalogError::InvalidProtocol); }
|
|
328
|
+
let rows = result.get("response").and_then(|v| v.get("models")).and_then(serde_json::Value::as_array).ok_or(CatalogError::UnsupportedSchema)?;
|
|
329
|
+
response = Some(rows.clone());
|
|
330
|
+
}
|
|
331
|
+
let rows = response.ok_or(CatalogError::InvalidProtocol)?;
|
|
332
|
+
if rows.is_empty() { return Err(CatalogError::Empty); }
|
|
333
|
+
let mut source_values = HashSet::new();
|
|
334
|
+
let mut by_id = HashMap::<String, usize>::new();
|
|
335
|
+
let mut records: Vec<ModelRecord> = Vec::new();
|
|
336
|
+
let mut group_defaults = Vec::<bool>::new();
|
|
337
|
+
let mut group_has_non_default_display = Vec::<bool>::new();
|
|
338
|
+
let mut any_default = false;
|
|
339
|
+
for row in rows {
|
|
340
|
+
let row = row.as_object().ok_or(CatalogError::Malformed)?;
|
|
341
|
+
let value = required_string(row.get("value"))?;
|
|
342
|
+
let resolved = required_string(row.get("resolvedModel")).map_err(|_| CatalogError::UnsupportedSchema)?;
|
|
343
|
+
let display = required_string(row.get("displayName")).map_err(|_| CatalogError::UnsupportedSchema)?;
|
|
344
|
+
validate_identity(value)?;
|
|
345
|
+
validate_identity(resolved)?;
|
|
346
|
+
validate_display(display)?;
|
|
347
|
+
if !source_values.insert(value.to_string()) { return Err(CatalogError::DuplicateIdentity(value.into())); }
|
|
348
|
+
let is_default = value == "default";
|
|
349
|
+
any_default |= is_default;
|
|
350
|
+
if let Some(index) = by_id.get(resolved).copied() {
|
|
351
|
+
let record = &mut records[index];
|
|
352
|
+
record.aliases.push(value.into());
|
|
353
|
+
if !is_default && !group_has_non_default_display[index] {
|
|
354
|
+
record.display_name = display.into();
|
|
355
|
+
group_has_non_default_display[index] = true;
|
|
356
|
+
}
|
|
357
|
+
group_defaults[index] |= is_default;
|
|
358
|
+
} else {
|
|
359
|
+
by_id.insert(resolved.into(), records.len());
|
|
360
|
+
records.push(ModelRecord { provider: provider.into(), vendor: "anthropic".into(), id: resolved.into(), display_name: display.into(), default: None, aliases: vec![value.into()] });
|
|
361
|
+
group_defaults.push(is_default);
|
|
362
|
+
group_has_non_default_display.push(!is_default);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (record, is_default) in records.iter_mut().zip(group_defaults) {
|
|
366
|
+
record.default = any_default.then_some(is_default);
|
|
367
|
+
}
|
|
368
|
+
Ok(records)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
fn required_string(value: Option<&serde_json::Value>) -> Result<&str, CatalogError> {
|
|
372
|
+
let value = value.and_then(serde_json::Value::as_str).ok_or(CatalogError::UnsupportedSchema)?;
|
|
373
|
+
if value.trim().is_empty() { return Err(CatalogError::UnsupportedSchema); }
|
|
374
|
+
Ok(value)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
fn validate_identity(value: &str) -> Result<(), CatalogError> {
|
|
378
|
+
if value.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { Err(CatalogError::Malformed) } else { Ok(()) }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fn validate_display(value: &str) -> Result<(), CatalogError> {
|
|
382
|
+
if value.chars().any(char::is_control) { Err(CatalogError::Malformed) } else { Ok(()) }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
pub(crate) trait CatalogObserver {
|
|
386
|
+
fn spawned(&mut self, _pid: u32) {}
|
|
387
|
+
fn exited(&mut self, _status: &ExitStatus) {}
|
|
388
|
+
fn output_timeout(&mut self) {}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
pub(crate) struct NoopObserver;
|
|
392
|
+
impl CatalogObserver for NoopObserver {}
|
|
393
|
+
|
|
394
|
+
fn run_native(
|
|
395
|
+
program: &str,
|
|
396
|
+
args: &[&str],
|
|
397
|
+
input: Option<&[u8]>,
|
|
398
|
+
cwd: Option<&Path>,
|
|
399
|
+
remove_env: &[&str],
|
|
400
|
+
timeout: Duration,
|
|
401
|
+
max_bytes: u64,
|
|
402
|
+
observer: &mut dyn CatalogObserver,
|
|
403
|
+
) -> Result<Vec<u8>, CatalogError> {
|
|
404
|
+
run_command(Path::new(program), args, input, cwd, remove_env, &[], timeout, max_bytes, observer)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
pub(crate) fn run_command(
|
|
408
|
+
program: &Path,
|
|
409
|
+
args: &[&str],
|
|
410
|
+
input: Option<&[u8]>,
|
|
411
|
+
cwd: Option<&Path>,
|
|
412
|
+
remove_env: &[&str],
|
|
413
|
+
extra_env: &[(OsString, OsString)],
|
|
414
|
+
timeout: Duration,
|
|
415
|
+
max_bytes: u64,
|
|
416
|
+
observer: &mut dyn CatalogObserver,
|
|
417
|
+
) -> Result<Vec<u8>, CatalogError> {
|
|
418
|
+
let started = Instant::now();
|
|
419
|
+
let mut command = Command::new(program);
|
|
420
|
+
command.args(args).stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }).stdout(Stdio::piped()).stderr(Stdio::null());
|
|
421
|
+
if let Some(cwd) = cwd { command.current_dir(cwd); }
|
|
422
|
+
for name in remove_env { command.env_remove(name); }
|
|
423
|
+
command.envs(extra_env.iter().cloned());
|
|
424
|
+
#[cfg(unix)]
|
|
425
|
+
{
|
|
426
|
+
use std::os::unix::process::CommandExt;
|
|
427
|
+
command.process_group(0);
|
|
428
|
+
}
|
|
429
|
+
let mut child = command.spawn().map_err(|_| CatalogError::ExecutableUnavailable)?;
|
|
430
|
+
let pid = child.id();
|
|
431
|
+
observer.spawned(pid);
|
|
432
|
+
let stdout = match child.stdout.take() {
|
|
433
|
+
Some(stdout) => stdout,
|
|
434
|
+
None => { terminate(&mut child, pid); return Err(CatalogError::OutputUnavailable); }
|
|
435
|
+
};
|
|
436
|
+
let (reader_tx, reader_rx) = mpsc::channel();
|
|
437
|
+
let reader = match std::thread::Builder::new().name("model-catalog-reader".into()).spawn(move || {
|
|
438
|
+
let mut bytes = Vec::new();
|
|
439
|
+
let result = stdout.take(max_bytes.saturating_add(1)).read_to_end(&mut bytes);
|
|
440
|
+
let _ = reader_tx.send(result.map(|_| bytes));
|
|
441
|
+
}) {
|
|
442
|
+
Ok(reader) => reader,
|
|
443
|
+
Err(_) => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
|
|
444
|
+
};
|
|
445
|
+
let writer = if let Some(input) = input {
|
|
446
|
+
let Some(mut stdin) = child.stdin.take() else { terminate(&mut child, pid); return Err(CatalogError::InputWriteFailed); };
|
|
447
|
+
let input = input.to_vec();
|
|
448
|
+
let (tx, rx) = mpsc::channel();
|
|
449
|
+
let writer = match std::thread::Builder::new().name("model-catalog-writer".into()).spawn(move || {
|
|
450
|
+
let result = stdin.write_all(&input);
|
|
451
|
+
drop(stdin);
|
|
452
|
+
let _ = tx.send(result.is_ok());
|
|
453
|
+
}) {
|
|
454
|
+
Ok(writer) => writer,
|
|
455
|
+
Err(_) => { terminate(&mut child, pid); return Err(CatalogError::InputWriteFailed); }
|
|
456
|
+
};
|
|
457
|
+
Some((writer, rx))
|
|
458
|
+
} else { None };
|
|
459
|
+
let mut output = None;
|
|
460
|
+
let mut too_large_at = None;
|
|
461
|
+
let status = loop {
|
|
462
|
+
match reader_rx.try_recv() {
|
|
463
|
+
Ok(Ok(bytes)) => {
|
|
464
|
+
if bytes.len() as u64 > max_bytes {
|
|
465
|
+
too_large_at.get_or_insert_with(Instant::now);
|
|
466
|
+
} else {
|
|
467
|
+
output = Some(bytes);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
Ok(Err(_)) => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
|
|
471
|
+
Err(mpsc::TryRecvError::Disconnected) if output.is_none() && too_large_at.is_none() => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
|
|
472
|
+
Err(mpsc::TryRecvError::Disconnected) | Err(mpsc::TryRecvError::Empty) => {}
|
|
473
|
+
}
|
|
474
|
+
match child.try_wait() {
|
|
475
|
+
Ok(Some(status)) => {
|
|
476
|
+
observer.exited(&status);
|
|
477
|
+
if !status.success() { terminate(&mut child, pid); return Err(CatalogError::CommandFailed); }
|
|
478
|
+
if too_large_at.is_some() { terminate(&mut child, pid); return Err(CatalogError::OutputTooLarge); }
|
|
479
|
+
break status;
|
|
480
|
+
}
|
|
481
|
+
Ok(None) if too_large_at.is_some_and(|at| at.elapsed() >= Duration::from_millis(100)) => {
|
|
482
|
+
terminate(&mut child, pid);
|
|
483
|
+
return Err(CatalogError::OutputTooLarge);
|
|
484
|
+
}
|
|
485
|
+
Ok(None) if started.elapsed() < timeout => std::thread::sleep(Duration::from_millis(10)),
|
|
486
|
+
Ok(None) if too_large_at.is_some() => { terminate(&mut child, pid); return Err(CatalogError::OutputTooLarge); }
|
|
487
|
+
Ok(None) => { terminate(&mut child, pid); return Err(CatalogError::Timeout); }
|
|
488
|
+
Err(_) => { terminate(&mut child, pid); return Err(CatalogError::CommandCouldNotBeObserved); }
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
let _ = status;
|
|
492
|
+
if let Some((writer, result)) = writer {
|
|
493
|
+
let remaining = timeout.saturating_sub(started.elapsed());
|
|
494
|
+
match result.recv_timeout(remaining) {
|
|
495
|
+
Ok(true) => { let _ = writer.join(); }
|
|
496
|
+
Ok(false) | Err(mpsc::RecvTimeoutError::Disconnected) => { terminate(&mut child, pid); return Err(CatalogError::InputWriteFailed); }
|
|
497
|
+
Err(mpsc::RecvTimeoutError::Timeout) => { terminate(&mut child, pid); return Err(CatalogError::Timeout); }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if output.is_none() {
|
|
501
|
+
let remaining = timeout.saturating_sub(started.elapsed());
|
|
502
|
+
match reader_rx.recv_timeout(remaining) {
|
|
503
|
+
Ok(Ok(bytes)) if bytes.len() as u64 <= max_bytes => output = Some(bytes),
|
|
504
|
+
Ok(Ok(_)) => { terminate(&mut child, pid); return Err(CatalogError::OutputTooLarge); }
|
|
505
|
+
Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => return Err(CatalogError::OutputReadFailed),
|
|
506
|
+
Err(mpsc::RecvTimeoutError::Timeout) => { observer.output_timeout(); terminate(&mut child, pid); return Err(CatalogError::Timeout); }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
let _ = reader.join();
|
|
510
|
+
output.ok_or(CatalogError::OutputReadFailed)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
fn terminate(child: &mut Child, pid: u32) {
|
|
514
|
+
#[cfg(unix)]
|
|
515
|
+
unsafe {
|
|
516
|
+
// The process group was created for this one native catalog invocation.
|
|
517
|
+
let _ = libc::kill(-(pid as i32), libc::SIGKILL);
|
|
518
|
+
}
|
|
519
|
+
let _ = child.kill();
|
|
520
|
+
let _ = child.wait();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
pub(crate) fn finish_stdout_after_exit(
|
|
524
|
+
stdout: std::process::ChildStdout,
|
|
525
|
+
status: ExitStatus,
|
|
526
|
+
timeout: Duration,
|
|
527
|
+
max_bytes: u64,
|
|
528
|
+
observer: &mut dyn CatalogObserver,
|
|
529
|
+
) -> Result<Vec<u8>, CatalogError> {
|
|
530
|
+
if !status.success() { observer.exited(&status); return Err(CatalogError::CommandFailed); }
|
|
531
|
+
observer.exited(&status);
|
|
532
|
+
let (tx, rx) = mpsc::channel();
|
|
533
|
+
let reader = std::thread::Builder::new().name("model-catalog-reader".into()).spawn(move || {
|
|
534
|
+
let mut bytes = Vec::new();
|
|
535
|
+
let result = stdout.take(max_bytes.saturating_add(1)).read_to_end(&mut bytes);
|
|
536
|
+
let _ = tx.send(result.map(|_| bytes));
|
|
537
|
+
}).map_err(|_| CatalogError::OutputReadFailed)?;
|
|
538
|
+
let result = match rx.recv_timeout(timeout) {
|
|
539
|
+
Ok(Ok(bytes)) if bytes.len() as u64 <= max_bytes => Ok(bytes),
|
|
540
|
+
Ok(Ok(_)) => Err(CatalogError::OutputTooLarge),
|
|
541
|
+
Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => Err(CatalogError::OutputReadFailed),
|
|
542
|
+
Err(mpsc::RecvTimeoutError::Timeout) => { observer.output_timeout(); Err(CatalogError::Timeout) }
|
|
543
|
+
};
|
|
544
|
+
if !matches!(&result, Err(CatalogError::Timeout)) { let _ = reader.join(); }
|
|
545
|
+
result
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
struct OwnedTempDir(PathBuf);
|
|
549
|
+
impl OwnedTempDir {
|
|
550
|
+
fn create() -> Result<Self, CatalogError> {
|
|
551
|
+
use std::os::unix::fs::DirBuilderExt;
|
|
552
|
+
let root = std::env::temp_dir();
|
|
553
|
+
for _ in 0..8 {
|
|
554
|
+
let id = TEMP_DIR_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
|
555
|
+
let path = root.join(format!("team-agent-claude-models-{}-{id}", std::process::id()));
|
|
556
|
+
let mut builder = std::fs::DirBuilder::new();
|
|
557
|
+
builder.mode(0o700);
|
|
558
|
+
match builder.create(&path) {
|
|
559
|
+
Ok(()) => return Ok(Self(path)),
|
|
560
|
+
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
561
|
+
Err(_) => return Err(CatalogError::TemporaryDirectoryUnavailable),
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
Err(CatalogError::TemporaryDirectoryUnavailable)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
impl Drop for OwnedTempDir {
|
|
568
|
+
fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#[cfg(test)]
|
|
572
|
+
mod tests {
|
|
573
|
+
use super::*;
|
|
574
|
+
|
|
575
|
+
#[test]
|
|
576
|
+
fn provider_capability_is_explicit_and_exact() {
|
|
577
|
+
assert_eq!(parse_catalog_provider("claude_code"), Some(Provider::ClaudeCode));
|
|
578
|
+
assert_eq!(parse_catalog_provider("cloud"), None);
|
|
579
|
+
assert!(catalog_source(Provider::Codex).is_some());
|
|
580
|
+
assert!(catalog_source(Provider::Fake).is_none());
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#[test]
|
|
584
|
+
fn codex_visibility_and_validation_are_fail_closed() {
|
|
585
|
+
let bytes = br#"{"models":[{"slug":"gpt-6-luna","display_name":"GPT Luna","visibility":"list"},{"slug":"hidden","display_name":"Hidden","visibility":"hide"}]}"#;
|
|
586
|
+
let rows = parse_codex_catalog(bytes, "codex").unwrap();
|
|
587
|
+
assert_eq!(rows.len(), 1);
|
|
588
|
+
assert_eq!(rows[0].id, "gpt-6-luna");
|
|
589
|
+
assert_eq!(rows[0].vendor, "openai");
|
|
590
|
+
let duplicate = br#"{"models":[{"slug":"x","display_name":"X","visibility":"hide"},{"slug":"x","display_name":"X","visibility":"list"}]}"#;
|
|
591
|
+
assert!(matches!(parse_codex_catalog(duplicate, "codex"), Err(CatalogError::DuplicateIdentity(_))));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
#[test]
|
|
595
|
+
fn claude_selectors_group_by_exact_resolved_id() {
|
|
596
|
+
let response = serde_json::json!({"type":"control_response","request_id":CLAUDE_REQUEST_ID,"response":{"subtype":"success","response":{"models":[
|
|
597
|
+
{"value":"default","resolvedModel":"claude-opus-5-5[1m]","displayName":"Default"},
|
|
598
|
+
{"value":"opus[1m]","resolvedModel":"claude-opus-5-5[1m]","displayName":"Opus 1M"},
|
|
599
|
+
{"value":"opus-alt","resolvedModel":"claude-opus-5-5[1m]","displayName":"Alternate display"},
|
|
600
|
+
{"value":"sonnet","resolvedModel":"claude-sonnet-5","displayName":"Sonnet"}
|
|
601
|
+
]}}});
|
|
602
|
+
let bytes = format!("{}\n", response).into_bytes();
|
|
603
|
+
let rows = parse_claude_catalog(&bytes, "claude_code").unwrap();
|
|
604
|
+
assert_eq!(rows.len(), 2);
|
|
605
|
+
assert_eq!(rows[0].provider, "claude_code");
|
|
606
|
+
assert_eq!(rows[0].id, "claude-opus-5-5[1m]");
|
|
607
|
+
assert_eq!(rows[0].display_name, "Opus 1M");
|
|
608
|
+
assert_eq!(rows[0].aliases, vec!["default", "opus[1m]", "opus-alt"]);
|
|
609
|
+
assert_eq!(rows[0].default, Some(true));
|
|
610
|
+
assert_eq!(rows[1].default, Some(false));
|
|
611
|
+
assert!(model_matches(&rows[0], "OPUS 1M"));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
#[test]
|
|
615
|
+
fn claude_ignores_other_stream_frames_and_matches_nested_request_id() {
|
|
616
|
+
let models = serde_json::json!([{"value":"default","resolvedModel":"claude-sonnet-5","displayName":"Sonnet"}]);
|
|
617
|
+
let frames = [
|
|
618
|
+
serde_json::json!({"type":"system","subtype":"hook_started"}),
|
|
619
|
+
serde_json::json!({"type":"system","subtype":"hook_response"}),
|
|
620
|
+
serde_json::json!({"type":"stream_event","event":{"type":"message_start"}}),
|
|
621
|
+
serde_json::json!({"type":"control_response","response":{"subtype":"success","request_id":CLAUDE_REQUEST_ID,"response":{"models":models}}}),
|
|
622
|
+
];
|
|
623
|
+
let bytes = frames.iter().map(serde_json::Value::to_string).collect::<Vec<_>>().join("\n");
|
|
624
|
+
let rows = parse_claude_catalog(bytes.as_bytes(), "claude_code").unwrap();
|
|
625
|
+
assert_eq!(rows.len(), 1);
|
|
626
|
+
assert_eq!(rows[0].id, "claude-sonnet-5");
|
|
627
|
+
|
|
628
|
+
let conflicting = serde_json::json!({"type":"control_response","request_id":CLAUDE_REQUEST_ID,"response":{"subtype":"success","request_id":"wrong-id","response":{"models":[{"value":"default","resolvedModel":"claude-sonnet-5","displayName":"Sonnet"}]}}});
|
|
629
|
+
assert!(matches!(parse_claude_catalog(format!("{}\n", conflicting).as_bytes(), "claude_code"), Err(CatalogError::InvalidProtocol)));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
#[test]
|
|
633
|
+
fn claude_missing_canonical_id_and_duplicate_selectors_fail_closed() {
|
|
634
|
+
let missing = serde_json::json!({"type":"control_response","request_id":CLAUDE_REQUEST_ID,"response":{"subtype":"success","response":{"models":[{"value":"default","displayName":"Default"}]}}});
|
|
635
|
+
assert!(matches!(parse_claude_catalog(format!("{}\n", missing).as_bytes(), "claude"), Err(CatalogError::UnsupportedSchema)));
|
|
636
|
+
let duplicate = serde_json::json!({"type":"control_response","request_id":CLAUDE_REQUEST_ID,"response":{"subtype":"success","response":{"models":[
|
|
637
|
+
{"value":"opus","resolvedModel":"claude-opus","displayName":"Opus"},
|
|
638
|
+
{"value":"opus","resolvedModel":"claude-opus[1m]","displayName":"Opus 1M"}
|
|
639
|
+
]}}});
|
|
640
|
+
assert!(matches!(parse_claude_catalog(format!("{}\n", duplicate).as_bytes(), "claude"), Err(CatalogError::DuplicateIdentity(_))));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
#[test]
|
|
644
|
+
fn matcher_uses_and_tokens_and_all_catalog_fields() {
|
|
645
|
+
let row = ModelRecord { provider: "codex".into(), vendor: "openai".into(), id: "gpt-6-luna".into(), display_name: "GPT Luna".into(), default: None, aliases: vec![] };
|
|
646
|
+
assert!(model_matches(&row, "LUNA GPT"));
|
|
647
|
+
assert!(!model_matches(&row, "LUNA absent"));
|
|
648
|
+
assert!(model_matches(&row, " "));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
@@ -1,20 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
use super::
|
|
3
|
-
|
|
4
|
-
#[test]
|
|
5
|
-
fn mcp_reapply_scope_matches_legacy_helpers() {
|
|
6
|
-
assert!(
|
|
7
|
-
reapply_scope(&StateWriteIntent::McpUpdateStateNote {
|
|
8
|
-
team_key: Some("team-a"),
|
|
9
|
-
}) == ReapplyScope::Team
|
|
10
|
-
);
|
|
11
|
-
assert!(
|
|
12
|
-
reapply_scope(&StateWriteIntent::McpAssignTask {
|
|
13
|
-
team_key: Some("team-a"),
|
|
14
|
-
task_id: "task-a",
|
|
15
|
-
}) == ReapplyScope::Root
|
|
16
|
-
);
|
|
17
|
-
}
|
|
2
|
+
use super::StateWriteIntent;
|
|
18
3
|
|
|
19
4
|
#[test]
|
|
20
5
|
fn optional_unmigrated_read_preserves_missing_corrupt_and_valid_shapes(
|