Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions crates/jp_cli/src/cmd/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<(String, String, String)>, crate::Error> {
let cwd = env::current_dir().map_err(|error| {
crate::Error::CliConfig(format!("cannot read the current directory: {error}"))
Expand Down
108 changes: 106 additions & 2 deletions crates/jp_cli/src/cmd/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -248,6 +252,19 @@ pub(crate) struct Query {
#[arg(short = 'm', long = "model")]
model: Option<String>,

/// 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
/// `<kind>:<name>`.
/// `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<AuthEntry>,

/// The model parameters to use.
#[arg(short = 'p', long = "param", value_name = "KEY=VALUE", action = ArgAction::Append)]
parameters: Vec<KvAssignment>,
Expand Down Expand Up @@ -2359,6 +2376,7 @@ impl IntoPartialAppConfig for Query {
) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
let Self {
model,
auth,
template: _,
schema: _,
replay: _,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <provider>/<name>`, or set the chain directly with `--cfg \
providers.llm.<provider>.auth={}`",
auth.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.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<ProviderId> {
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<String, PartialModelIdOrAliasConfig>,
depth: u8,
) -> Option<ProviderId> {
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.
Expand Down
173 changes: 173 additions & 0 deletions crates/jp_cli/src/cmd/query_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <provider>/<name>"), "{message}");
assert!(
message.contains("providers.llm.<provider>.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);
}
Loading