Skip to content
Merged
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
4 changes: 3 additions & 1 deletion llm-router/src/provider_scaffold/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ impl ScaffoldCache {
iii: &IIIClient,
provider_id: &str,
token: Option<&str>,
credential_env_var: Option<&str>,
) -> Result<ProviderResolveResponse, Error> {
if let Some(resolved) = self.fresh_resolve() {
return Ok(resolved);
}
// The lock is never held across the await; concurrent misses may
// duplicate one resolve, which is harmless.
let resolved = super::router_client::resolve(iii, provider_id, token).await?;
let resolved =
super::router_client::resolve(iii, provider_id, token, credential_env_var).await?;
*self.resolve.write().expect("resolve cache lock poisoned") =
Some((resolved.clone(), Instant::now()));
Ok(resolved)
Expand Down
116 changes: 113 additions & 3 deletions llm-router/src/provider_scaffold/router_client.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! Thin wrappers over the router's provider-protocol functions. All calls
//! carry the registration token (identity binding, spec adaptation #1).
//! `provider_id` is the caller's declared provider id (e.g. "anthropic").
use crate::types::credential::Credential;
use crate::types::model::Model;
use crate::types::router::ProviderResolveResponse;
use crate::types::router::{CredentialSource, ProviderResolveResponse};
use iii_sdk::errors::Error;
use iii_sdk::protocol::TriggerRequest;
use iii_sdk::IIIClient;
Expand All @@ -23,17 +24,19 @@ pub async fn resolve(
iii: &IIIClient,
provider_id: &str,
token: Option<&str>,
credential_env_var: Option<&str>,
) -> Result<ProviderResolveResponse, Error> {
let mut payload = json!({ "id": provider_id });
if let Some(t) = token {
payload["token"] = json!(t);
}
let raw = call(iii, "router::provider::resolve", payload).await?;
serde_json::from_value(raw).map_err(|e| Error::Remote {
let resp: ProviderResolveResponse = serde_json::from_value(raw).map_err(|e| Error::Remote {
code: "provider/bad_resolve_response".into(),
message: e.to_string(),
stacktrace: None,
})
})?;
Ok(apply_credential_env_fallback(resp, credential_env_var))
}

/// `router::models::reconcile` — replace this provider's catalog slice.
Expand Down Expand Up @@ -70,3 +73,110 @@ pub async fn models_get(iii: &IIIClient, provider_id: &str, model_id: &str) -> O
pub async fn register(iii: &IIIClient, declaration: Value) -> Result<Value, Error> {
call(iii, "router::provider::register", declaration).await
}

/// Inject an env-sourced ApiKey only when the router resolved nothing.
/// Pure: takes the already-read value so tests never touch process env.
fn with_api_key_fallback(
mut resp: ProviderResolveResponse,
key: Option<String>,
) -> ProviderResolveResponse {
if resp.credential.is_some() {
return resp; // router / config credential always wins
}
if let Some(k) = key {
let k = k.trim();
if !k.is_empty() {
resp.credential = Some(Credential::ApiKey { key: k.to_string() });
resp.source = CredentialSource::Env;
resp.configured = true;
}
}
resp
}

/// Read the provider's declared env var and apply the fallback.
pub fn apply_credential_env_fallback(
resp: ProviderResolveResponse,
credential_env_var: Option<&str>,
) -> ProviderResolveResponse {
let key = credential_env_var.and_then(|name| std::env::var(name).ok());
with_api_key_fallback(resp, key)
}

#[cfg(test)]
mod fallback_tests {
use super::with_api_key_fallback;
use crate::types::credential::Credential;
use crate::types::router::{CredentialSource, ProviderResolveResponse};

fn none_resp() -> ProviderResolveResponse {
ProviderResolveResponse {
configured: false,
source: CredentialSource::None,
credential: None,
api_url: None,
max_tokens: None,
}
}

#[test]
fn router_credential_wins_over_env() {
let mut resp = none_resp();
resp.credential = Some(Credential::ApiKey {
key: "from-router".into(),
});
resp.source = CredentialSource::Config;
resp.configured = true;
let out = with_api_key_fallback(resp, Some("from-env".into()));
assert_eq!(
out.credential,
Some(Credential::ApiKey {
key: "from-router".into()
})
);
assert_eq!(out.source, CredentialSource::Config);
}

#[test]
fn injects_env_when_router_has_none() {
let out = with_api_key_fallback(none_resp(), Some("sk-abc".into()));
assert_eq!(
out.credential,
Some(Credential::ApiKey {
key: "sk-abc".into()
})
);
assert_eq!(out.source, CredentialSource::Env);
assert!(out.configured);
}

#[test]
fn no_key_leaves_none() {
let out = with_api_key_fallback(none_resp(), None);
assert_eq!(out.credential, None);
assert!(!out.configured);
}

#[test]
fn empty_and_whitespace_are_not_injected() {
assert_eq!(
with_api_key_fallback(none_resp(), Some("".into())).credential,
None
);
assert_eq!(
with_api_key_fallback(none_resp(), Some(" \n".into())).credential,
None
);
}

#[test]
fn injected_key_is_trimmed() {
let out = with_api_key_fallback(none_resp(), Some(" sk-abc\n".into()));
assert_eq!(
out.credential,
Some(Credential::ApiKey {
key: "sk-abc".into()
})
);
}
}
14 changes: 13 additions & 1 deletion provider-anthropic/src/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;

/// Env var the router (and, as a fallback, this provider) reads for the key.
pub const CREDENTIAL_ENV_VAR: &str = "ANTHROPIC_API_KEY";

pub fn declaration() -> ProviderDeclaration {
ProviderDeclaration {
id: PROVIDER_ID.into(),
display_name: Some("Anthropic".into()),
credential_env_var: Some("ANTHROPIC_API_KEY".into()),
credential_env_var: Some(CREDENTIAL_ENV_VAR.into()),
defaults: Some(ProviderDefaults {
api_url: Some(DEFAULT_API_URL.into()),
max_tokens: Some(DEFAULT_MAX_TOKENS),
Expand Down Expand Up @@ -210,4 +213,13 @@ mod tests {
assert!(prompt.contains("agent_trigger"));
assert!(prompt.contains("IMPORTANT: NEVER invent function ids"));
}

#[test]
fn declaration_uses_credential_env_var_const() {
assert_eq!(super::CREDENTIAL_ENV_VAR, "ANTHROPIC_API_KEY");
assert_eq!(
declaration().credential_env_var.as_deref(),
Some(super::CREDENTIAL_ENV_VAR)
);
}
}
8 changes: 7 additions & 1 deletion provider-anthropic/src/router_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ pub async fn resolve(
iii: &IIIClient,
token: Option<&str>,
) -> Result<ProviderResolveResponse, Error> {
scaffold::resolve(iii, PROVIDER_ID, token).await
scaffold::resolve(
iii,
PROVIDER_ID,
token,
Some(crate::register::CREDENTIAL_ENV_VAR),
)
.await
}

/// `router::models::reconcile` — replace this provider's catalog slice.
Expand Down
7 changes: 6 additions & 1 deletion provider-anthropic/src/stream_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ async fn run_stream_call(
// stays the router's job.
let token = cache.load_token(iii, state::STATE_SCOPE).await;
let resolved = match cache
.resolve(iii, crate::PROVIDER_ID, token.as_deref())
.resolve(
iii,
crate::PROVIDER_ID,
token.as_deref(),
Some(crate::register::CREDENTIAL_ENV_VAR),
)
.await
{
Ok(r) => r,
Expand Down
14 changes: 13 additions & 1 deletion provider-kimi/src/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;

/// Env var the router (and, as a fallback, this provider) reads for the key.
pub const CREDENTIAL_ENV_VAR: &str = "MOONSHOT_API_KEY";

pub fn declaration() -> ProviderDeclaration {
ProviderDeclaration {
id: PROVIDER_ID.into(),
display_name: Some("Kimi".into()),
credential_env_var: Some("MOONSHOT_API_KEY".into()),
credential_env_var: Some(CREDENTIAL_ENV_VAR.into()),
defaults: Some(ProviderDefaults {
api_url: Some(DEFAULT_API_URL.into()),
max_tokens: Some(DEFAULT_MAX_TOKENS),
Expand Down Expand Up @@ -184,4 +187,13 @@ mod tests {
assert!(prompt.contains("agent_trigger"));
assert!(prompt.contains("Never invent function ids"));
}

#[test]
fn declaration_uses_credential_env_var_const() {
assert_eq!(super::CREDENTIAL_ENV_VAR, "MOONSHOT_API_KEY");
assert_eq!(
declaration().credential_env_var.as_deref(),
Some(super::CREDENTIAL_ENV_VAR)
);
}
}
10 changes: 8 additions & 2 deletions provider-kimi/src/router_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ pub async fn resolve(
payload["token"] = json!(t);
}
let raw = call(iii, "router::provider::resolve", payload).await?;
serde_json::from_value(raw).map_err(|e| Error::Remote {
let resp: ProviderResolveResponse = serde_json::from_value(raw).map_err(|e| Error::Remote {
code: "provider/bad_resolve_response".into(),
message: e.to_string(),
stacktrace: None,
})
})?;
Ok(
llm_router::provider_scaffold::router_client::apply_credential_env_fallback(
resp,
Some(crate::register::CREDENTIAL_ENV_VAR),
),
)
}

/// `router::models::reconcile` — replace this provider's catalog slice.
Expand Down
7 changes: 6 additions & 1 deletion provider-llamacpp/src/embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ pub async fn handle(
// stays the router's job.
let token = cache.load_token(iii, state::STATE_SCOPE).await;
let resolved = cache
.resolve(iii, crate::PROVIDER_ID, token.as_deref())
.resolve(
iii,
crate::PROVIDER_ID,
token.as_deref(),
Some(crate::register::CREDENTIAL_ENV_VAR),
)
.await
.inspect_err(|e| {
if classify_bus_error(e) == ErrorKind::AuthExpired {
Expand Down
14 changes: 13 additions & 1 deletion provider-llamacpp/src/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;

/// Env var the router (and, as a fallback, this provider) reads for the key.
pub const CREDENTIAL_ENV_VAR: &str = "LLAMACPP_API_KEY";

pub fn declaration() -> ProviderDeclaration {
ProviderDeclaration {
id: PROVIDER_ID.into(),
display_name: Some("llama.cpp".into()),
credential_env_var: Some("LLAMACPP_API_KEY".into()),
credential_env_var: Some(CREDENTIAL_ENV_VAR.into()),
defaults: Some(ProviderDefaults {
api_url: Some(DEFAULT_API_URL.into()),
max_tokens: Some(DEFAULT_MAX_TOKENS),
Expand Down Expand Up @@ -213,4 +216,13 @@ mod tests {
assert!(prompt.contains("agent_trigger"));
assert!(prompt.contains("IMPORTANT: NEVER invent function ids"));
}

#[test]
fn declaration_uses_credential_env_var_const() {
assert_eq!(super::CREDENTIAL_ENV_VAR, "LLAMACPP_API_KEY");
assert_eq!(
declaration().credential_env_var.as_deref(),
Some(super::CREDENTIAL_ENV_VAR)
);
}
}
8 changes: 7 additions & 1 deletion provider-llamacpp/src/router_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ pub async fn resolve(
iii: &IIIClient,
token: Option<&str>,
) -> Result<ProviderResolveResponse, Error> {
scaffold::resolve(iii, PROVIDER_ID, token).await
scaffold::resolve(
iii,
PROVIDER_ID,
token,
Some(crate::register::CREDENTIAL_ENV_VAR),
)
.await
}

/// `router::models::reconcile` — replace this provider's catalog slice.
Expand Down
7 changes: 6 additions & 1 deletion provider-llamacpp/src/stream_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ async fn run_stream_call(
// stays the router's job.
let token = cache.load_token(iii, state::STATE_SCOPE).await;
let resolved = match cache
.resolve(iii, crate::PROVIDER_ID, token.as_deref())
.resolve(
iii,
crate::PROVIDER_ID,
token.as_deref(),
Some(crate::register::CREDENTIAL_ENV_VAR),
)
.await
{
Ok(r) => r,
Expand Down
2 changes: 1 addition & 1 deletion provider-openai-codex/src/router_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ pub async fn resolve(
iii: &IIIClient,
token: Option<&str>,
) -> Result<ProviderResolveResponse, Error> {
scaffold::resolve(iii, PROVIDER_ID, token).await
scaffold::resolve(iii, PROVIDER_ID, token, None).await
}

/// `router::models::reconcile` — replace this provider's catalog slice.
Expand Down
2 changes: 1 addition & 1 deletion provider-openai-codex/src/stream_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async fn run_stream_call(
// An auth-classified failure drops the cache so the next attempt
// re-resolves fresh — retrying stays the router's job.
let resolved = match cache
.resolve(iii, crate::PROVIDER_ID, token.as_deref())
.resolve(iii, crate::PROVIDER_ID, token.as_deref(), None)
.await
{
Ok(r) => r,
Expand Down
7 changes: 6 additions & 1 deletion provider-openai/src/embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ pub async fn handle(
// stays the router's job.
let token = cache.load_token(iii, state::STATE_SCOPE).await;
let resolved = match cache
.resolve(iii, crate::PROVIDER_ID, token.as_deref())
.resolve(
iii,
crate::PROVIDER_ID,
token.as_deref(),
Some(crate::register::CREDENTIAL_ENV_VAR),
)
.await
{
Ok(r) => r,
Expand Down
Loading
Loading