Skip to content

Commit 68b5b30

Browse files
committed
feat: add OpenRouter OAuth login
1 parent 8729c84 commit 68b5b30

12 files changed

Lines changed: 1578 additions & 61 deletions

File tree

README.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,41 @@ local record; if remote revocation is unavailable, credentials are retained. Use
103103
Kit refreshes expiring credentials proactively and retries one rejected credential
104104
with a synchronized forced refresh.
105105

106-
To use OpenRouter, set `OPENROUTER_API_KEY`, select the provider, and pass an
107-
OpenRouter model identifier:
106+
To use OpenRouter, sign in to the shared credential store or supply an API key,
107+
then select the provider and pass an OpenRouter model identifier:
108108

109109
```sh
110-
OPENROUTER_API_KEY=sk-or-v1-... cargo run -- prompt \
110+
cargo run -- auth login openrouter --credential-store keychain
111+
cargo run -- auth status openrouter --credential-store keychain
112+
cargo run -- prompt --credential-store keychain \
111113
--provider openrouter --model anthropic/claude-sonnet-4 \
112114
--root /path/to/project "Reply with a short project summary"
115+
cargo run -- auth logout openrouter --credential-store keychain
113116
```
114117

118+
Runtime and OpenRouter auth status resolve keys in this order: a non-empty global
119+
`--openrouter-api-key`, a non-empty `OPENROUTER_API_KEY`, then the stored
120+
`openrouter/default` credential. An empty flag is rejected; an empty environment value
121+
is treated as absent. The flag is available to `serve`, `acp`, `prompt`, `tui`, and `auth`, but its value is
122+
present in Kit's initial process argv and can be exposed by shell history or process
123+
inspection. Prefer the environment or stored credentials. Kit never rewrites its own
124+
process environment and never adds the key to child argv. It overrides
125+
`OPENROUTER_API_KEY` only in the TUI's Kit `serve` child and built-in or configured
126+
`acp.kit` children. Kit removes its resolved OpenRouter key from external ACP child
127+
environments rather than exposing the credential to arbitrary configured agents.
128+
129+
`kit auth status openrouter` reports whether the active source is the flag,
130+
environment, or stored credentials without printing the key. Logout removes only the
131+
stored record and cannot revoke an OpenRouter key automatically. If the flag or
132+
`OPENROUTER_API_KEY` remains set, logout warns that it is still active; the
133+
environment value is retained.
134+
115135
Kit uses the selected `--model` or configured `model`; `OPENROUTER_MODEL` does not
116136
override it. The adapter also honors its optional `OPENROUTER_BASE_URL`,
117137
`OPENROUTER_APP_NAME`, `OPENROUTER_SITE_URL`, `OPENROUTER_MAX_COMPLETION_TOKENS`,
118-
`OPENROUTER_TEMPERATURE`, and `OPENROUTER_REASONING_EFFORT` settings.
138+
`OPENROUTER_TEMPERATURE`, and `OPENROUTER_REASONING_EFFORT` settings. Stored OAuth
139+
credentials are restricted to OpenRouter's canonical API endpoint; custom base URLs
140+
require an explicit flag or environment key.
119141

120142
Kit also reads the OpenRouter model catalog's
121143
`context_length` for the selected model, enabling the normal context gauge and
@@ -212,7 +234,9 @@ explicit `[acp.kit]` overrides that executable/base argv. In both cases Kit then
212234
appends root, provider, model, the resolved reasoning effort, persistent session, resume, MCP, credential, and inherited
213235
depth flags, and the profile remains eligible for isolated Kit transcript fork
214236
fallback. Other profiles remain literal generic ACP argv. Configured child
215-
processes inherit Kit's environment unchanged in this release.
237+
processes inherit Kit's environment except for resolved OpenRouter credentials:
238+
`acp.kit` receives the key as a child-only `OPENROUTER_API_KEY` override, while
239+
generic ACP profiles have `OPENROUTER_API_KEY` removed.
216240
Missing config files are ignored; unreadable or invalid files and unknown selected
217241
harness references produce an error rather than being silently discarded.
218242

@@ -446,7 +470,7 @@ refusal, protocol errors, and other uncertain stops retire the child instead. Pe
446470
headless child are conservatively cancelled so they cannot hang. Nested built-in
447471
children use `kit acp` and never start A2A listeners.
448472

449-
OpenAI, Speakeasy, and MCP use one shared credential backend, selected with
473+
OpenAI, OpenRouter, Speakeasy, and MCP use one shared credential backend, selected with
450474
`--credential-store` or `credential_store`. The default `memory` backend is
451475
process-local, so credentials disappear at exit and are not available to the
452476
TUI server process or nested Kit children. Use persistent storage when those

src/acp_child.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ impl AcpHarnesses {
226226
.ok_or_else(|| format!("unknown ACP harness {reference:?}"))?;
227227
let mut command = Command::new(&profile.command);
228228
command.args(&profile.args);
229+
command.env_remove("OPENROUTER_API_KEY");
229230
command
230231
};
231232
// Every trusted profile is spawned directly (never through a shell)
@@ -282,6 +283,9 @@ impl AcpHarnesses {
282283
command.arg("--credential-dir").arg(path);
283284
}
284285
config.telemetry.append_cli_args(&mut command);
286+
if let Some(api_key) = &config.openrouter_api_key {
287+
command.env("OPENROUTER_API_KEY", api_key.as_str());
288+
}
285289
Ok(command)
286290
}
287291
}
@@ -307,6 +311,7 @@ pub(crate) fn serve_command(
307311
model: &str,
308312
provider: crate::ProviderKind,
309313
reasoning_effort: Option<crate::ReasoningEffort>,
314+
openrouter_api_key: Option<&crate::provider::OpenRouterApiKey>,
310315
session_id: &str,
311316
resume: bool,
312317
) -> std::io::Result<Command> {
@@ -326,6 +331,9 @@ pub(crate) fn serve_command(
326331
if resume {
327332
command.arg("--resume");
328333
}
334+
if let Some(api_key) = openrouter_api_key {
335+
command.env("OPENROUTER_API_KEY", api_key.as_str());
336+
}
329337
Ok(command)
330338
}
331339

@@ -335,6 +343,7 @@ pub(crate) struct ChildConfig {
335343
pub model: String,
336344
pub provider: crate::ProviderKind,
337345
pub reasoning_effort: Option<crate::ReasoningEffort>,
346+
pub openrouter_api_key: Option<crate::provider::OpenRouterApiKey>,
338347
pub mcp_config: Option<PathBuf>,
339348
pub credential_storage: CredentialStorage,
340349
pub telemetry: crate::telemetry::Settings,
@@ -1047,6 +1056,7 @@ mod tests {
10471056
"test-model",
10481057
crate::ProviderKind::OpenRouter,
10491058
Some(crate::ReasoningEffort::Medium),
1059+
Some(&crate::provider::OpenRouterApiKey::new("tui-secret")),
10501060
"session",
10511061
true,
10521062
)
@@ -1060,6 +1070,48 @@ mod tests {
10601070
args.windows(2)
10611071
.any(|pair| pair == ["--reasoning-effort", "medium"])
10621072
);
1073+
assert!(args.iter().all(|arg| arg != "tui-secret"));
1074+
assert!(command.as_std().get_envs().any(|(name, value)| {
1075+
name == "OPENROUTER_API_KEY" && value == Some(std::ffi::OsStr::new("tui-secret"))
1076+
}));
1077+
}
1078+
1079+
#[test]
1080+
fn openrouter_key_is_removed_from_external_acp_profiles() {
1081+
let root = tempfile::tempdir().unwrap();
1082+
let harnesses = AcpHarnesses::new(BTreeMap::from([(
1083+
"external".into(),
1084+
AcpHarnessProfile {
1085+
command: "external-agent".into(),
1086+
args: Vec::new(),
1087+
permissions: AcpPermissionPolicy::Deny,
1088+
},
1089+
)]))
1090+
.unwrap();
1091+
for openrouter_api_key in [
1092+
Some(crate::provider::OpenRouterApiKey::new("external-secret")),
1093+
None,
1094+
] {
1095+
let config = ChildConfig {
1096+
root: root.path().into(),
1097+
model: "model".into(),
1098+
provider: crate::ProviderKind::OpenRouter,
1099+
reasoning_effort: None,
1100+
openrouter_api_key,
1101+
mcp_config: None,
1102+
credential_storage: Default::default(),
1103+
telemetry: Default::default(),
1104+
harnesses: harnesses.clone(),
1105+
default_harness: "acp.external".into(),
1106+
};
1107+
let command = harnesses.spawn("acp.external", &config, None, 1).unwrap();
1108+
assert!(
1109+
command
1110+
.as_std()
1111+
.get_envs()
1112+
.any(|(name, value)| { name == "OPENROUTER_API_KEY" && value.is_none() })
1113+
);
1114+
}
10631115
}
10641116

10651117
#[test]
@@ -1235,6 +1287,7 @@ mod tests {
12351287
model: "unused".into(),
12361288
provider: Default::default(),
12371289
reasoning_effort: None,
1290+
openrouter_api_key: None,
12381291
mcp_config: None,
12391292
credential_storage: Default::default(),
12401293
telemetry: Default::default(),
@@ -1285,6 +1338,7 @@ mod tests {
12851338
model: "unused".into(),
12861339
provider: Default::default(),
12871340
reasoning_effort: None,
1341+
openrouter_api_key: None,
12881342
mcp_config: None,
12891343
credential_storage: Default::default(),
12901344
telemetry: Default::default(),
@@ -1334,6 +1388,7 @@ mod tests {
13341388
model: "unused".into(),
13351389
provider: Default::default(),
13361390
reasoning_effort: None,
1391+
openrouter_api_key: None,
13371392
mcp_config: None,
13381393
credential_storage: Default::default(),
13391394
telemetry: Default::default(),
@@ -1370,6 +1425,7 @@ mod tests {
13701425
model: "test-model".into(),
13711426
provider: crate::ProviderKind::OpenRouter,
13721427
reasoning_effort: Some(crate::ReasoningEffort::High),
1428+
openrouter_api_key: Some(crate::provider::OpenRouterApiKey::new("child-secret")),
13731429
mcp_config: None,
13741430
credential_storage: CredentialStorage::Filesystem(root.path().join("credentials")),
13751431
telemetry: crate::telemetry::Settings::try_new(
@@ -1439,6 +1495,10 @@ mod tests {
14391495
.any(|pair| { pair == ["--otel-message-content-max-bytes", "4096"] })
14401496
);
14411497
assert_eq!(command.as_std().get_current_dir(), Some(root.path()));
1498+
assert!(args.iter().all(|arg| arg != "child-secret"));
1499+
assert!(command.as_std().get_envs().any(|(name, value)| {
1500+
name == "OPENROUTER_API_KEY" && value == Some(std::ffi::OsStr::new("child-secret"))
1501+
}));
14421502
}
14431503

14441504
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -1458,6 +1518,7 @@ mod tests {
14581518
model: "unused".into(),
14591519
provider: Default::default(),
14601520
reasoning_effort: None,
1521+
openrouter_api_key: None,
14611522
mcp_config: None,
14621523
credential_storage: Default::default(),
14631524
telemetry: Default::default(),
@@ -1516,6 +1577,7 @@ mod tests {
15161577
model: "unused".into(),
15171578
provider: Default::default(),
15181579
reasoning_effort: None,
1580+
openrouter_api_key: None,
15191581
mcp_config: None,
15201582
credential_storage: Default::default(),
15211583
telemetry: Default::default(),

0 commit comments

Comments
 (0)