diff --git a/crates/jp_cli/src/cmd/provider.rs b/crates/jp_cli/src/cmd/provider.rs index 5796ab740..857913019 100644 --- a/crates/jp_cli/src/cmd/provider.rs +++ b/crates/jp_cli/src/cmd/provider.rs @@ -673,9 +673,6 @@ fn configured_api_keys(printer: &Printer) -> Vec<(String, String, String)> { /// Reads the user-global config and the `.jp.toml` chain, but not a workspace's /// own config: the command runs before workspace discovery, since credentials /// are user-global. -/// -/// Reads the partial rather than a built [`AppConfig`], so a validation error -/// anywhere else in the config does not erase every row. fn read_api_keys() -> Result, crate::Error> { let cwd = env::current_dir().map_err(|error| { crate::Error::CliConfig(format!("cannot read the current directory: {error}")) diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 8add1c21d..17d0a0eff 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -86,9 +86,13 @@ use jp_config::{ }, }, fs::{expand_tilde, load_partial}, - model::parameters::{ - PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig, ServiceTier, + model::{ + id::{PartialModelIdOrAliasConfig, ProviderId}, + parameters::{ + PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig, ServiceTier, + }, }, + providers::llm::AuthEntry, style::{mcp_startup::McpStartupConfig, reasoning::ReasoningDisplayConfig}, }; use jp_conversation::{ @@ -248,6 +252,19 @@ pub(crate) struct Query { #[arg(short = 'm', long = "model")] model: Option, + /// Which credential to bill this turn to. + /// + /// Takes the same entries as the `auth` chain in configuration, comma + /// separated: a credential name, `api_key`, `subscription`, or + /// `:`. + /// `api` and `sub` are accepted for the kinds. + /// + /// Applies to the provider the chosen model belongs to, and is recorded on + /// the turn, so the rest of the conversation keeps billing the same way + /// until another `--auth` changes it. + #[arg(long = "auth", value_name = "CHAIN", value_delimiter = ',')] + auth: Vec, + /// The model parameters to use. #[arg(short = 'p', long = "param", value_name = "KEY=VALUE", action = ArgAction::Append)] parameters: Vec, @@ -2359,6 +2376,7 @@ impl IntoPartialAppConfig for Query { ) -> std::result::Result> { let Self { model, + auth, template: _, schema: _, replay: _, @@ -2389,6 +2407,7 @@ impl IntoPartialAppConfig for Query { } = &self; apply_model(&mut partial, model.as_deref(), merged_config); + apply_auth(&mut partial, auth, merged_config)?; // Must run before tool-enable processing, which reads the injected // `enable` blocks. @@ -2492,6 +2511,91 @@ fn build_thread( Ok(thread_builder.build()?) } +/// Write `--auth` to the `auth` chain of the provider serving this turn. +/// +/// Runs after [`apply_model`], since the provider comes from the turn's model. +/// A provider that cannot be determined is an error: writing the chain to the +/// wrong one would silently do nothing. +fn apply_auth( + partial: &mut PartialAppConfig, + auth: &[AuthEntry], + merged_config: Option<&PartialAppConfig>, +) -> BoxedResult<()> { + if auth.is_empty() { + return Ok(()); + } + + let provider = active_provider(partial, merged_config).ok_or_else(|| { + format!( + "--auth needs to know which provider to bill, and the model for this turn does not \ + name one; pass `--model /`, or set the chain directly with `--cfg \ + providers.llm..auth={}`", + auth.iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ) + })?; + + let auth = auth.to_vec(); + let llm = &mut partial.providers.llm; + match provider { + ProviderId::Anthropic => llm.anthropic.auth = Some(auth), + ProviderId::Cerebras => llm.cerebras.auth = Some(auth), + ProviderId::Deepseek => llm.deepseek.auth = Some(auth), + ProviderId::Google => llm.google.auth = Some(auth), + ProviderId::Openai => llm.openai.auth = Some(auth), + ProviderId::Openrouter => llm.openrouter.auth = Some(auth), + + provider @ (ProviderId::Llamacpp + | ProviderId::Ollama + | ProviderId::Test + | ProviderId::Xai) => { + return Err(format!( + "--auth is not supported for `{provider}`: it needs no credential" + ) + .into()); + } + } + + Ok(()) +} + +/// The provider serving this turn, if the config says which. +/// +/// Reads the CLI's `--model` first, then the config layers. +fn active_provider( + partial: &PartialAppConfig, + merged_config: Option<&PartialAppConfig>, +) -> Option { + let aliases = merged_config.map_or(&partial.providers.llm.aliases, |merged| { + &merged.providers.llm.aliases + }); + + [Some(partial), merged_config] + .into_iter() + .flatten() + .find_map(|config| provider_of(&config.assistant.model.id, aliases, 8)) +} + +/// Follow a model id, or an alias to the id it stands for, to its provider. +/// +/// `depth` bounds an alias chain that points at itself; the config pipeline +/// reports the cycle properly later. +fn provider_of( + id: &PartialModelIdOrAliasConfig, + aliases: &IndexMap, + depth: u8, +) -> Option { + match id { + PartialModelIdOrAliasConfig::Id(id) => id.provider, + PartialModelIdOrAliasConfig::Alias(alias) if depth > 0 => { + provider_of(aliases.get(alias.as_str())?, aliases, depth - 1) + } + PartialModelIdOrAliasConfig::Alias(_) => None, + } +} + /// Apply the CLI model configuration to the partial configuration. /// /// `model` is the raw `--model` value: an alias or a full `provider/name` ID. diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index 68434f9e0..5e16ea362 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -3491,3 +3491,176 @@ fn read_arg_file_error_names_the_path() { "unexpected message: {error}" ); } + +/// A partial naming `model` as the turn's model. +fn partial_with_model(model: PartialModelIdOrAliasConfig) -> PartialAppConfig { + let mut partial = PartialAppConfig::default(); + partial.assistant.model.id = model; + partial +} + +fn model_id(id: &str) -> PartialModelIdOrAliasConfig { + PartialModelIdOrAliasConfig::Id(id.parse().expect("a valid model id")) +} + +fn alias(name: &str) -> PartialModelIdOrAliasConfig { + PartialModelIdOrAliasConfig::Alias(name.to_owned()) +} + +/// The chain lands on the provider of the model serving the turn, and nowhere +/// else. +#[test] +fn test_auth_writes_the_chain_to_the_turns_provider() { + let mut partial = partial_with_model(model_id("openai/gpt-5.6-luna")); + let auth = vec![AuthEntry::Subscription(Some("personal".to_owned()))]; + + apply_auth(&mut partial, &auth, None).unwrap(); + + assert_eq!(partial.providers.llm.openai.auth.as_ref(), Some(&auth)); + assert_eq!(partial.providers.llm.anthropic.auth, None); + assert_eq!(partial.providers.llm.cerebras.auth, None); +} + +/// A whole chain is written, not only its first entry. +#[test] +fn test_auth_writes_every_entry_in_order() { + let mut partial = partial_with_model(model_id("anthropic/claude-haiku-4-5")); + let auth = vec![ + AuthEntry::Named("personal".to_owned()), + AuthEntry::ApiKey(None), + ]; + + apply_auth(&mut partial, &auth, None).unwrap(); + + assert_eq!(partial.providers.llm.anthropic.auth.as_ref(), Some(&auth)); +} + +/// An alias is followed to the provider it stands for. +#[test] +fn test_auth_follows_an_alias_to_its_provider() { + let mut partial = partial_with_model(alias("luna")); + partial + .providers + .llm + .aliases + .insert("luna".to_owned(), model_id("openai/gpt-5.6-luna")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap(); + + assert_eq!( + partial.providers.llm.openai.auth, + Some(vec![AuthEntry::ApiKey(None)]) + ); +} + +/// An alias naming another alias resolves through to the model id. +#[test] +fn test_auth_follows_a_chain_of_aliases() { + let mut partial = partial_with_model(alias("fast")); + partial + .providers + .llm + .aliases + .insert("fast".to_owned(), alias("luna")); + partial + .providers + .llm + .aliases + .insert("luna".to_owned(), model_id("openai/gpt-5.6-luna")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap(); + + assert!(partial.providers.llm.openai.auth.is_some()); +} + +/// An alias pointing at itself reports rather than recursing forever. +#[test] +fn test_auth_gives_up_on_an_alias_cycle() { + let mut partial = partial_with_model(alias("a")); + partial + .providers + .llm + .aliases + .insert("a".to_owned(), alias("b")); + partial + .providers + .llm + .aliases + .insert("b".to_owned(), alias("a")); + + let error = apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap_err(); + + assert!( + error.to_string().contains("--auth needs to know"), + "{error}" + ); +} + +/// The provider comes from the config layers when the CLI names no model. +#[test] +fn test_auth_reads_the_provider_from_the_merged_config() { + let mut partial = PartialAppConfig::default(); + let merged = partial_with_model(model_id("cerebras/gpt-oss-120b")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], Some(&merged)).unwrap(); + + assert!(partial.providers.llm.cerebras.auth.is_some()); +} + +/// The CLI's own model wins over the one the config layers settled on. +#[test] +fn test_auth_prefers_the_models_named_on_the_command_line() { + let mut partial = partial_with_model(model_id("openai/gpt-5.6-luna")); + let merged = partial_with_model(model_id("anthropic/claude-haiku-4-5")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], Some(&merged)).unwrap(); + + assert!(partial.providers.llm.openai.auth.is_some()); + assert_eq!(partial.providers.llm.anthropic.auth, None); +} + +/// An unresolvable provider names the `--cfg` form that would work, rather than +/// writing the chain somewhere it would do nothing. +#[test] +fn test_auth_without_a_provider_names_the_alternative() { + let mut partial = PartialAppConfig::default(); + let auth = vec![ + AuthEntry::Subscription(Some("personal".to_owned())), + AuthEntry::ApiKey(None), + ]; + + let error = apply_auth(&mut partial, &auth, None).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("--model /"), "{message}"); + assert!( + message.contains("providers.llm..auth=subscription:personal,api_key"), + "{message}" + ); +} + +/// A provider reached over a local socket has no credential to choose. +#[test] +fn test_auth_is_refused_for_a_provider_without_credentials() { + for model in ["llamacpp/qwen3", "ollama/qwen3"] { + let mut partial = partial_with_model(model_id(model)); + + let error = apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap_err(); + + assert!( + error.to_string().contains("needs no credential"), + "{model}: {error}" + ); + } +} + +/// Without the flag, nothing is written and no provider is looked for. +#[test] +fn test_auth_is_a_no_op_when_the_flag_is_absent() { + let mut partial = PartialAppConfig::default(); + + apply_auth(&mut partial, &[], None).unwrap(); + + assert_eq!(partial.providers.llm.openai.auth, None); + assert_eq!(partial.providers.llm.anthropic.auth, None); +}