From 10c14d08403d197d92a39b835bf45e024813a2df Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 10 Sep 2026 11:54:37 +0200 Subject: [PATCH 1/3] feat(cli): Add --auth to bill a turn to a chosen credential `jp q --auth personal` bills the turn to that credential, `--auth api_key` to per-token billing, and `--auth sub,api_key` tries the subscription first. It takes the same entries as the `auth` chain in configuration. The flag applies to the provider the turn's model belongs to and records a config delta, so the rest of the conversation bills the same way until another `--auth` changes it. Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/query.rs | 108 ++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) 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. From d1135a9bfb07e6a6223c4dfc33eaa2c986480ea2 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 10 Sep 2026 11:58:45 +0200 Subject: [PATCH 2/3] test(cli): Cover `--auth` provider resolution The flag picks which provider's chain to write by following the turn's model, and nothing pinned that. These cover the resolution: a plain model id, an alias, an alias naming another alias, the CLI's model winning over the config layers', and the config layers answering when the CLI names no model. Three cases are about refusing rather than resolving. An alias cycle gives up at a bounded depth instead of recursing forever, a provider reached over a local socket is refused by name, and a turn with no provider at all reports the `--cfg` form that would have worked. Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/query_tests.rs | 173 +++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) 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); +} From 42c0c8eb769ade929459d0fb5b3ee45c40b9c680 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sun, 13 Sep 2026 07:45:42 +0200 Subject: [PATCH 3/3] fixes Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/provider.rs | 3 --- 1 file changed, 3 deletions(-) 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}"))