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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
most on a USB stick or someone else's machine.
- Installable with [Scoop](https://scoop.sh), which also avoids the "unrecognized app" prompt.

## [0.2.1] — 2026-08-03

Two defects that shipped in 0.2.0, both found by using the app rather than reading it.

### Fixed

- **A mistyped or revoked key was accepted and saved.** OpenKey checked keys against an endpoint
that doesn't require one, so any text passed. You'd see "Key saved. You're ready to chat", then
every message would fail with advice to run `/reset` — which brought you back to the same screen.
Keys are now genuinely verified before being saved.
- **Piped or scripted input crashed the app.** Anything needing a menu — choosing a model,
confirming an erase, first-run setup — closed OpenKey with an error when input didn't come from a
keyboard. Those now fall back to typing a number or a word.
- Choosing a model said the choice lasted "until you close OpenKey". It's remembered.

## [0.2.0] — 2026-08-03

### Added
Expand Down Expand Up @@ -106,6 +121,7 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Commands: `/about`, `/models`, `/model`, `/cls`, `/help`, `/reset`, `/quit`.
- Single self-contained `.exe` that runs from a USB stick with nothing installed.

[Unreleased]: https://github.com/corecompiled/OpenKey/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/corecompiled/OpenKey/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/corecompiled/OpenKey/releases/tag/v0.2.1
[0.2.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.2.0
[0.1.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.1.0
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode>
<Version>0.2.0</Version>
<Version>0.2.1</Version>
<NeutralLanguage>en-US</NeutralLanguage>
</PropertyGroup>
</Project>
11 changes: 11 additions & 0 deletions docs/03-openrouter-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ new ModelInfo(
IsFree: true);
```

## Endpoint 1b — `GET /key` (validating a key)

`GET /api/v1/key` with the `Authorization` header. Returns 200 for a usable key and **401** for one
that is missing, mistyped, or revoked.

Use this — and only this — to check a key before saving it. **`GET /models` is public**: it answers
200 with no `Authorization` header at all, so validating against it accepts any string as a valid
key. That is not theoretical; it shipped. The symptom was a mistyped key being saved with "You're
ready to chat", then failing on every message, with the error advising a `/reset` that led straight
back to the same screen.

## Endpoint 2 — `POST /chat/completions` (streaming)

Request body:
Expand Down
44 changes: 44 additions & 0 deletions src/OpenKey.Providers.OpenRouter/OpenRouterProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,50 @@ public OpenRouterProvider(HttpClient http, Func<string?> keyProvider)
public string Id => "openrouter";
public string DisplayName => "OpenRouter";

/// <summary>
/// Checks that the key is actually accepted, throwing <see cref="ChatException"/> with
/// <see cref="ChatErrorKind.AuthFailure"/> when it is not.
/// <para>
/// Not part of <see cref="IChatProvider"/> — key acquisition is provider-specific, and both
/// hosts already construct this type directly to validate before saving.
/// </para>
/// <para>
/// This exists because <c>GET /models</c> is a <b>public</b> endpoint: it answers 200 with no
/// Authorization header at all. Validating against it accepted any string as a valid key, so a
/// mistyped key was saved with "You're ready to chat" and then failed on every message, with
/// the error advising a <c>/reset</c> that led straight back to the same place.
/// <c>GET /key</c> requires authentication and answers 401.
/// </para>
/// </summary>
public async Task ValidateKeyAsync(CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/key");
ApplyHeaders(req);

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(ModelListTimeout);

HttpResponseMessage resp;
try
{
resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new ChatException(ChatErrorKind.NetworkDown, "OpenRouter didn't respond in time.");
}
catch (Exception ex) when (IsNetwork(ex))
{
throw new ChatException(ChatErrorKind.NetworkDown, "Network unreachable.", null, ex);
}

if (resp.IsSuccessStatusCode) return;

await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
var body = await ReadBodyAsync(stream, cts.Token);
throw MapHttpError(resp, body);
}

public async Task<IReadOnlyList<ModelInfo>> ListModelsAsync(CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/models");
Expand Down
21 changes: 10 additions & 11 deletions src/OpenKey/CommandRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ public async Task<CommandResult> HandleAsync(string input, CancellationToken ct)
+ "You'll need to sign in again.",
"Only continue if you meant to start completely fresh.");

if (AnsiConsole.Confirm("Erase everything and start over?", defaultValue: false))
if (Prompts.Confirm("Erase everything and start over?"))
{
await _resetAction(ct);
}
Expand Down Expand Up @@ -350,16 +350,15 @@ await AnsiConsole.Status()
.Select(m => $"{Truncate(m.DisplayName, 44).PadRight(46)}{FormatContext(m.ContextLength)}")
.ToList();

var prompt = new SelectionPrompt<string>
{
Title = Components.PickerTitle("Which model should answer you?"),
PageSize = 12,
MoreChoicesText = $"[{Theme.Muted}]More below[/]",
};
prompt.AddChoice(AutoChoiceLabel);
foreach (var row in rows) prompt.AddChoice(row);
var choices = new List<string> { AutoChoiceLabel };
choices.AddRange(rows);

string choice = AnsiConsole.Prompt(prompt);
var choice = Prompts.Select("Which model should answer you?", choices);
if (choice is null)
{
Components.HintLine("Kept the current choice.");
return;
}

if (choice == AutoChoiceLabel)
{
Expand All @@ -371,7 +370,7 @@ await AnsiConsole.Status()
var picked = models[rows.IndexOf(choice)];
_engine.PreferredModelId = picked.Id;
Components.SuccessLine($"Now using {picked.DisplayName}.");
Components.HintLine("This lasts until you close OpenKey.");
Components.HintLine("Remembered for next time. Choose Auto to hand the choice back.");
}

private static string FormatContext(int contextLength) =>
Expand Down
22 changes: 11 additions & 11 deletions src/OpenKey/ConsoleHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,12 @@ private async Task<bool> EnsureFirstRunAsync(CancellationToken ct)
{
// No "(attempt 1/3)" counter: showing a retry budget before anything has failed
// manufactures anxiety. Retries are surfaced only after a failure.
var choice = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title(Components.PickerTitle("How would you like to connect?"))
.AddChoices(OAuthChoice, PasteChoice));
var choice = Prompts.Select("How would you like to connect?", new[] { OAuthChoice, PasteChoice });
if (choice is null)
{
Components.HintLine("No option chosen.");
continue;
}

string? key = null;

Expand Down Expand Up @@ -471,12 +473,7 @@ private static string AcquireKeyViaPaste()
Components.HintLine("You can create one at https://openrouter.ai/keys");
AnsiConsole.WriteLine();

return AnsiConsole.Prompt(
new TextPrompt<string>("Key: ")
.Secret()
.Validate(k => string.IsNullOrWhiteSpace(k)
? ValidationResult.Error($"[{Theme.Danger}]Paste a key to continue, or press Ctrl+C to go back.[/]")
: ValidationResult.Success()));
return Prompts.Secret("Key: ") ?? string.Empty;
}

private async Task<bool> ValidateAndSaveAsync(string key, CancellationToken ct)
Expand All @@ -490,6 +487,9 @@ await AnsiConsole.Status()
.SpinnerStyle(new Style(Color.Grey))
.StartAsync($"[{Theme.Muted}]Checking your key[/]", async _ =>
{
// Auth check first: /models is public and answers 200 for anyone, so on its
// own it would accept any string as a valid key.
await tmp.ValidateKeyAsync(ct);
models = await tmp.ListModelsAsync(ct);
});

Expand Down Expand Up @@ -525,7 +525,7 @@ await AnsiConsole.Status()
ex.Message,
"OpenKey can save the key now and check it the first time you chat.");

if (AnsiConsole.Confirm("Save the key and continue?", defaultValue: false))
if (Prompts.Confirm("Save the key and continue?"))
{
_keyStore.Save(key);
return true;
Expand Down
106 changes: 106 additions & 0 deletions src/OpenKey/Ui/Prompts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using Spectre.Console;

namespace OpenKey.Ui;

/// <summary>
/// Interactive prompts that degrade instead of throwing.
/// <para>
/// Spectre's prompts raise <see cref="NotSupportedException"/> when the terminal is not
/// interactive — which is true whenever any standard stream is redirected, so it happens for
/// `openkey &lt; script.txt`, for piped input, and inside CI. Nothing caught it, so a piped
/// <c>/models</c> killed the app with a raw exception name on screen.
/// </para>
/// <para>
/// Every fallback here reads a plain line instead, which is exactly what a piped caller can
/// provide. Destructive confirmations deliberately require an explicit word rather than accepting
/// a bare newline.
/// </para>
/// </summary>
internal static class Prompts
{
/// <summary>True when Spectre's own prompts are safe to use.</summary>
public static bool CanPrompt => ConsoleLayout.Rich;

public static bool Confirm(string question, bool defaultValue = false)
{
if (CanPrompt)
{
try { return AnsiConsole.Confirm(question, defaultValue); }
catch (NotSupportedException) { /* fall through */ }
}

AnsiConsole.Markup($"{Markup.Escape(question)} [{Theme.Muted}](yes/no)[/] ");
var answer = Console.ReadLine();
if (Console.IsInputRedirected) AnsiConsole.WriteLine();

// No answer means no. A destructive default of "yes" on EOF would be indefensible.
return answer?.Trim().ToLowerInvariant() is "y" or "yes";
}

/// <summary>
/// Presents a choice. Returns the selected item, or null when the user declined or nothing
/// could be read.
/// </summary>
public static string? Select(string title, IReadOnlyList<string> choices)
{
if (choices.Count == 0) return null;

if (CanPrompt)
{
try
{
var prompt = new SelectionPrompt<string>
{
Title = Components.PickerTitle(title),
PageSize = 12,
MoreChoicesText = $"[{Theme.Muted}]More below[/]",
};
foreach (var c in choices) prompt.AddChoice(c);
return AnsiConsole.Prompt(prompt);
}
catch (NotSupportedException) { /* fall through */ }
}

// Numbered list is the only sane fallback: a piped caller cannot press arrow keys.
AnsiConsole.MarkupLine(Markup.Escape(title));
for (var i = 0; i < choices.Count; i++)
AnsiConsole.MarkupLine($" [{Theme.Brand}]{i + 1}[/] {Markup.Escape(choices[i])}");

Components.HintLine("Type a number and press Enter, or press Enter to cancel.");
AnsiConsole.Markup($"[{Theme.Brand}]#[/] ");

var line = Console.ReadLine();
if (Console.IsInputRedirected) AnsiConsole.WriteLine();

return int.TryParse(line?.Trim(), out var pick) && pick >= 1 && pick <= choices.Count
? choices[pick - 1]
: null;
}

/// <summary>
/// Reads a secret. Masked when the terminal allows it; when it does not, the value is coming
/// from a pipe and there is nothing on screen to hide.
/// </summary>
public static string? Secret(string label)
{
if (CanPrompt)
{
try
{
return AnsiConsole.Prompt(
new TextPrompt<string>(label)
.Secret()
.Validate(k => string.IsNullOrWhiteSpace(k)
? ValidationResult.Error($"[{Theme.Danger}]Paste a key to continue, or press Ctrl+C to go back.[/]")
: ValidationResult.Success()));
}
catch (NotSupportedException) { /* fall through */ }
}

AnsiConsole.Markup(Markup.Escape(label));
var value = Console.ReadLine();
if (Console.IsInputRedirected) AnsiConsole.WriteLine();

return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
32 changes: 32 additions & 0 deletions tests/OpenKey.Tests/OpenRouterProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@

var chunks = await CollectAsync(Provider(sse));

var final = Assert.Single(chunks.Where(c => c.IsFinal));

Check warning on line 81 in tests/OpenKey.Tests/OpenRouterProviderTests.cs

View workflow job for this annotation

GitHub Actions / build

Do not use a Where clause to filter before calling Assert.Single. Use the overload of Assert.Single that accepts a filtering function. (https://xunit.net/xunit.analyzers/rules/xUnit2031)

Check warning on line 81 in tests/OpenKey.Tests/OpenRouterProviderTests.cs

View workflow job for this annotation

GitHub Actions / build

Do not use a Where clause to filter before calling Assert.Single. Use the overload of Assert.Single that accepts a filtering function. (https://xunit.net/xunit.analyzers/rules/xUnit2031)
Assert.Equal("stop", final.FinishReason);
}

Expand All @@ -99,6 +99,38 @@
Assert.Equal("x", string.Concat(chunks.Select(c => c.DeltaText)));
}

[Fact]
public async Task ValidatingAKeyRejectsA401()
{
// Key validation used to call ListModelsAsync, but GET /models is a *public* endpoint that
// answers 200 with no Authorization header at all — so any string passed validation. A
// mistyped key was saved with "You're ready to chat" and then failed on every message,
// with the error advising a /reset that led straight back to the same place.
var provider = Provider("""{"error":{"message":"No auth credentials found"}}""", HttpStatusCode.Unauthorized);

var ex = await Assert.ThrowsAsync<ChatException>(
() => provider.ValidateKeyAsync(CancellationToken.None));

Assert.Equal(ChatErrorKind.AuthFailure, ex.Kind);
}

[Fact]
public async Task ValidatingAKeyAcceptsSuccess()
{
var provider = Provider("""{"data":{"label":"test","usage":0}}""");

await provider.ValidateKeyAsync(CancellationToken.None); // must not throw
}

[Fact]
public async Task ValidatingAKeyReportsNetworkFailureSeparately()
{
// A connection problem must not be reported to the user as "your key was refused".
var provider = Provider("<html>captive portal</html>", HttpStatusCode.OK, "text/html");

await provider.ValidateKeyAsync(CancellationToken.None); // 200 is 200; parsing is not its job
}

[Fact]
public async Task CaptivePortalHtmlDoesNotCrash()
{
Expand Down
Loading