• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 18194738131

02 Oct 2025 01:38PM UTC coverage: 44.217% (-0.02%) from 44.232%
18194738131

push

github

web-flow
feat(config): Add model alias support to simplify model configuration (#264)

Users can now specify model aliases instead of full model IDs when
configuring models. The system supports both direct model IDs like
`anthropic/claude-3-5-sonnet` and simple aliases like `claude` or
`sonnet` that are resolved through the `providers.llm.aliases`
configuration.

This change introduces `ModelIdOrAliasConfig` enum that can hold either
a concrete `ModelIdConfig` or a string alias. The `finalize()` method
resolves aliases to concrete model IDs by looking them up in the
`aliases` configuration, falling back to parsing as a direct model ID if
no alias is found.

The CLI already supported referencing models by their alias, but now you
can do the same in configuration files.

---------

Signed-off-by: Jean Mertz <git@jeanmertz.com>

58 of 118 new or added lines in 10 files covered. (49.15%)

1 existing line in 1 file now uncovered.

6182 of 13981 relevant lines covered (44.22%)

5.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.01
/crates/jp_cli/src/cmd/init.rs
1
use std::{env, fs, path::PathBuf, str::FromStr as _};
2

3
use crossterm::style::Stylize as _;
4
use duct::cmd;
5
use jp_config::{
6
    conversation::tool::RunMode,
7
    model::id::{ModelIdConfig, Name, PartialModelIdConfig, ProviderId},
8
    PartialAppConfig,
9
};
10
use jp_workspace::Workspace;
11
use path_clean::PathClean as _;
12

13
use crate::{ctx::IntoPartialAppConfig, Output, DEFAULT_STORAGE_DIR};
14

15
#[derive(Debug, clap::Args)]
16
pub(crate) struct Init {
17
    /// Path to initialize the workspace at. Defaults to the current directory.
18
    path: Option<PathBuf>,
19
}
20

21
impl Init {
22
    pub(crate) fn run(&self) -> Output {
×
23
        let cwd = std::env::current_dir()?;
×
24
        let mut root = self
×
25
            .path
×
26
            .clone()
×
27
            .unwrap_or_else(|| PathBuf::from("."))
×
28
            .clean();
×
29

30
        if !root.is_absolute() {
×
31
            root = cwd.join(root);
×
32
        }
×
33

34
        fs::create_dir_all(&root)?;
×
35

36
        let storage = root.join(DEFAULT_STORAGE_DIR);
×
37
        let id = jp_workspace::Id::new();
×
38
        jp_id::global::set(id.to_string());
×
39

40
        let mut workspace =
×
41
            Workspace::new_with_id(root.clone(), id.clone()).persisted_at(&storage)?;
×
42

43
        id.store(&storage)?;
×
44

45
        workspace = workspace.with_local_storage()?;
×
46

47
        let mut config = default_config();
×
48
        if let Some(id) = default_model() {
×
49
            print!("Using model {}", id.to_string().bold().blue());
×
50
            let note = "  (to use a different model, update `.jp/config.toml`)".to_owned();
×
51
            println!("{}\n", note.grey().italic());
×
52

×
53
            config.assistant.model.id = PartialModelIdConfig {
×
54
                provider: Some(id.provider),
×
55
                name: Some(id.name),
×
NEW
56
            }
×
NEW
57
            .into();
×
UNCOV
58
        }
×
59

60
        let data = toml::to_string_pretty(&config)?;
×
61
        fs::write(storage.join("config.toml"), data)?;
×
62
        fs::create_dir_all(storage.join("config.d"))?;
×
63

64
        workspace.persist()?;
×
65

66
        let loc = if root == cwd {
×
67
            "current directory".to_owned()
×
68
        } else {
69
            root.to_string_lossy().bold().to_string()
×
70
        };
71

72
        Ok(format!("Initialized workspace at {loc}").into())
×
73
    }
×
74
}
75

76
#[expect(clippy::too_many_lines)]
77
fn default_config() -> jp_config::PartialAppConfig {
1✔
78
    let mut cfg = jp_config::PartialAppConfig::default();
1✔
79
    cfg.extends
1✔
80
        .get_or_insert_default()
1✔
81
        .push("config.d/**/*".into());
1✔
82

83
    // This is a required field without a default value (that is, the
84
    // `ToolsDefaultsConfig` type does not set a default value for `run`).
85
    //
86
    // By setting it explicitly, we ensure that the default generated config
87
    // file has this value set, which exposes it to the user. This is desired,
88
    // as this is an important security feature, which we don't want users to
89
    // have to rely on a default value that might change in the future.
90
    cfg.conversation.tools.defaults.run = Some(RunMode::Ask);
1✔
91

92
    if has_anthropic() {
1✔
93
        cfg.providers.llm.aliases.extend([
1✔
94
            ("anthropic".to_owned(), PartialModelIdConfig {
1✔
95
                provider: Some(ProviderId::Anthropic),
1✔
96
                name: Some(Name("claude-sonnet-4-0".into())),
1✔
97
            }),
1✔
98
            ("claude".to_owned(), PartialModelIdConfig {
1✔
99
                provider: Some(ProviderId::Anthropic),
1✔
100
                name: Some(Name("claude-sonnet-4-0".into())),
1✔
101
            }),
1✔
102
            ("sonnet".to_owned(), PartialModelIdConfig {
1✔
103
                provider: Some(ProviderId::Anthropic),
1✔
104
                name: Some(Name("claude-sonnet-4-0".into())),
1✔
105
            }),
1✔
106
            ("opus".to_owned(), PartialModelIdConfig {
1✔
107
                provider: Some(ProviderId::Anthropic),
1✔
108
                name: Some(Name("claude-opus-4-1".into())),
1✔
109
            }),
1✔
110
            ("haiku".to_owned(), PartialModelIdConfig {
1✔
111
                provider: Some(ProviderId::Anthropic),
1✔
112
                name: Some(Name("claude-3-5-haiku-latest".into())),
1✔
113
            }),
1✔
114
        ]);
1✔
115
    }
1✔
116

117
    if has_openai() {
1✔
118
        cfg.providers.llm.aliases.extend([
1✔
119
            ("openai".to_owned(), PartialModelIdConfig {
1✔
120
                provider: Some(ProviderId::Openai),
1✔
121
                name: Some(Name("gpt-5".into())),
1✔
122
            }),
1✔
123
            ("chatgpt".to_owned(), PartialModelIdConfig {
1✔
124
                provider: Some(ProviderId::Openai),
1✔
125
                name: Some(Name("gpt-5".into())),
1✔
126
            }),
1✔
127
            ("gpt".to_owned(), PartialModelIdConfig {
1✔
128
                provider: Some(ProviderId::Openai),
1✔
129
                name: Some(Name("gpt-5".into())),
1✔
130
            }),
1✔
131
            ("gpt5".to_owned(), PartialModelIdConfig {
1✔
132
                provider: Some(ProviderId::Openai),
1✔
133
                name: Some(Name("gpt-5".into())),
1✔
134
            }),
1✔
135
            ("gpt5-mini".to_owned(), PartialModelIdConfig {
1✔
136
                provider: Some(ProviderId::Openai),
1✔
137
                name: Some(Name("gpt-5-mini".into())),
1✔
138
            }),
1✔
139
            ("gpt-mini".to_owned(), PartialModelIdConfig {
1✔
140
                provider: Some(ProviderId::Openai),
1✔
141
                name: Some(Name("gpt-5-mini".into())),
1✔
142
            }),
1✔
143
            ("gpt5-nano".to_owned(), PartialModelIdConfig {
1✔
144
                provider: Some(ProviderId::Openai),
1✔
145
                name: Some(Name("gpt-5-nano".into())),
1✔
146
            }),
1✔
147
            ("gpt-nano".to_owned(), PartialModelIdConfig {
1✔
148
                provider: Some(ProviderId::Openai),
1✔
149
                name: Some(Name("gpt-5-nano".into())),
1✔
150
            }),
1✔
151
            ("o3-research".to_owned(), PartialModelIdConfig {
1✔
152
                provider: Some(ProviderId::Openai),
1✔
153
                name: Some(Name("o3-deep-research".into())),
1✔
154
            }),
1✔
155
            ("o4-mini-research".to_owned(), PartialModelIdConfig {
1✔
156
                provider: Some(ProviderId::Openai),
1✔
157
                name: Some(Name("o4-mini-deep-research".into())),
1✔
158
            }),
1✔
159
            ("codex".to_owned(), PartialModelIdConfig {
1✔
160
                provider: Some(ProviderId::Openai),
1✔
161
                name: Some(Name("gpt-5-codex".into())),
1✔
162
            }),
1✔
163
            ("gpt-5-codex".to_owned(), PartialModelIdConfig {
1✔
164
                provider: Some(ProviderId::Openai),
1✔
165
                name: Some(Name("gpt-5-codex".into())),
1✔
166
            }),
1✔
167
            ("codex-mini".to_owned(), PartialModelIdConfig {
1✔
168
                provider: Some(ProviderId::Openai),
1✔
169
                name: Some(Name("codex-mini-latest".into())),
1✔
170
            }),
1✔
171
        ]);
1✔
172
    }
1✔
173

174
    if has_google() {
1✔
175
        cfg.providers.llm.aliases.extend([
1✔
176
            ("google".to_owned(), PartialModelIdConfig {
1✔
177
                provider: Some(ProviderId::Google),
1✔
178
                name: Some(Name("gemini-pro-latest".into())),
1✔
179
            }),
1✔
180
            ("gemini".to_owned(), PartialModelIdConfig {
1✔
181
                provider: Some(ProviderId::Google),
1✔
182
                name: Some(Name("gemini-pro-latest".into())),
1✔
183
            }),
1✔
184
            ("gemini-pro".to_owned(), PartialModelIdConfig {
1✔
185
                provider: Some(ProviderId::Google),
1✔
186
                name: Some(Name("gemini-pro-latest".into())),
1✔
187
            }),
1✔
188
            ("gemini-flash".to_owned(), PartialModelIdConfig {
1✔
189
                provider: Some(ProviderId::Google),
1✔
190
                name: Some(Name("gemini-flash-latest".into())),
1✔
191
            }),
1✔
192
            ("gemini-lite".to_owned(), PartialModelIdConfig {
1✔
193
                provider: Some(ProviderId::Google),
1✔
194
                name: Some(Name("gemini-flash-lite-latest".into())),
1✔
195
            }),
1✔
196
        ]);
1✔
197
    }
1✔
198

199
    cfg
1✔
200
}
1✔
201

202
fn has_anthropic() -> bool {
1✔
203
    env::var("ANTHROPIC_API_KEY").is_ok()
1✔
204
}
1✔
205

206
fn has_openai() -> bool {
1✔
207
    env::var("OPENAI_API_KEY").is_ok()
1✔
208
}
1✔
209

210
fn has_google() -> bool {
1✔
211
    env::var("GOOGLE_API_KEY").is_ok()
1✔
212
}
1✔
213

214
fn default_model() -> Option<ModelIdConfig> {
×
215
    env::var("JP_CFG_ASSISTANT_MODEL_ID")
×
216
        .ok()
×
217
        .and_then(|v| ModelIdConfig::from_str(&v).ok())
×
218
        .or_else(|| {
×
219
            let models = cmd!("ollama", "list")
×
220
                .pipe(cmd!("cut", "-d", " ", "-f1"))
×
221
                .pipe(cmd!("tail", "-n+2"))
×
222
                .read()
×
223
                .unwrap_or_default();
×
224

225
            let models = models.lines().map(str::trim).collect::<Vec<_>>();
×
226
            let model = if let Some(model) = models.iter().find(|m| m.starts_with("llama")) {
×
227
                model
×
228
            } else if let Some(model) = models.iter().find(|m| m.starts_with("gemma")) {
×
229
                model
×
230
            } else if let Some(model) = models.iter().find(|m| m.starts_with("qwen")) {
×
231
                model
×
232
            } else {
233
                return None;
×
234
            };
235

236
            format!("ollama/{model}").parse().ok()
×
237
        })
×
238
        // TODO: Use `Config` env vars here.
239
        .or_else(|| {
×
240
            env::var("ANTHROPIC_API_KEY")
×
241
                .is_ok()
×
242
                .then(|| "anthropic/claude-sonnet-4-0".parse().ok())
×
243
                .flatten()
×
244
        })
×
245
        .or_else(|| {
×
246
            env::var("OPENAI_API_KEY")
×
247
                .is_ok()
×
248
                .then(|| "openai/o4-mini".parse().ok())
×
249
                .flatten()
×
250
        })
×
251
        .or_else(|| {
×
252
            env::var("GEMINI_API_KEY")
×
253
                .is_ok()
×
254
                .then(|| "google/gemini-2.5-flash-preview-05-20".parse().ok())
×
255
                .flatten()
×
256
        })
×
257
}
×
258

259
impl IntoPartialAppConfig for Init {
260
    fn apply_cli_config(
×
261
        &self,
×
262
        _workspace: Option<&Workspace>,
×
263
        partial: PartialAppConfig,
×
264
        _: Option<&PartialAppConfig>,
×
265
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
266
        Ok(partial)
×
267
    }
×
268
}
269

270
#[cfg(test)]
271
mod tests {
272
    use serial_test::serial;
273

274
    use super::*;
275

276
    pub(crate) struct EnvVarGuard {
277
        name: String,
278
        original_value: Option<String>,
279
    }
280

281
    impl EnvVarGuard {
282
        pub fn set(name: &str, value: &str) -> Self {
3✔
283
            let name = name.to_string();
3✔
284
            let original_value = std::env::var(&name).ok();
3✔
285
            unsafe { std::env::set_var(&name, value) };
3✔
286
            Self {
3✔
287
                name,
3✔
288
                original_value,
3✔
289
            }
3✔
290
        }
3✔
291
    }
292

293
    impl Drop for EnvVarGuard {
294
        fn drop(&mut self) {
3✔
295
            if let Some(ref original) = self.original_value {
3✔
296
                unsafe { std::env::set_var(&self.name, original) };
×
297
            } else {
3✔
298
                unsafe { std::env::remove_var(&self.name) };
3✔
299
            }
3✔
300
        }
3✔
301
    }
302

303
    #[test]
304
    #[serial(env_vars)]
305
    fn test_default_config() {
1✔
306
        let _env1 = EnvVarGuard::set("ANTHROPIC_API_KEY", "foo");
1✔
307
        let _env2 = EnvVarGuard::set("OPENAI_API_KEY", "bar");
1✔
308
        let _env3 = EnvVarGuard::set("GOOGLE_API_KEY", "baz");
1✔
309

310
        let config = default_config();
1✔
311

312
        insta::assert_toml_snapshot!(config);
1✔
313
    }
314
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc