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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T04:08:49Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: cca617df6db2e076
raw_hash: 93e262486d850a9c
vouched: 2026-10-22T21:13:07Z
body_hash: e0d9256e67814623
raw_hash: a6afeafd1ea5bd24
vouched: 2026-10-22T22:21:15Z
lines: 266-519
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T06:00:20Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: cca617df6db2e076
raw_hash: 93e262486d850a9c
vouched: 2026-10-22T21:13:07Z
body_hash: e0d9256e67814623
raw_hash: a6afeafd1ea5bd24
vouched: 2026-10-22T22:21:16Z
lines: 281-561
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-19T21:09:57Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: cca617df6db2e076
raw_hash: 93e262486d850a9c
vouched: 2026-10-22T21:13:07Z
body_hash: e0d9256e67814623
raw_hash: a6afeafd1ea5bd24
vouched: 2026-10-22T22:21:16Z
lines: 384-700
supersedes: 01M5APK9YCQH467YNMKAC8BT5B
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 94af0091ce8ea05b
raw_hash: 43d4d53a6b1fecd9
vouched: 2026-10-22T21:42:37Z
body_hash: d40dd86c1ca9274b
raw_hash: b0a5d34f855ac98b
vouched: 2026-10-22T22:21:16Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T14:00:47Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 94af0091ce8ea05b
raw_hash: 43d4d53a6b1fecd9
vouched: 2026-10-22T21:42:37Z
body_hash: d40dd86c1ca9274b
raw_hash: b0a5d34f855ac98b
vouched: 2026-10-22T22:21:16Z
lines: 1-1309
---

Expand Down
6 changes: 3 additions & 3 deletions apps/app/src/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled
<Row
id={`${id}-label`}
label={provider.name}
description={provider.override
? `${provider.override} overrides Keychain`
: sources[provider.source]}
description={`${
provider.override ? `${provider.override} overrides Keychain` : sources[provider.source]
}${provider.rejected ? `, but ${provider.name} rejected it` : ""}`}
footer={
<>
{editing && (
Expand Down
2 changes: 1 addition & 1 deletion apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ async function handle(
await setKey(request.provider, request.value);
return settingsChanged();
case "key-check":
return checkKey(request.provider);
return checkKey(request.provider, settingsChanged);
case "key-remove":
await removeKey(request.provider);
return settingsChanged();
Expand Down
14 changes: 14 additions & 0 deletions apps/host/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,25 @@ const known = new Map<string, Read>();
/** When Ace Helper last saved or removed each name; a read started before then is stale. */
const changed = new Map<string, number>();

/** Names whose current value its provider rejected at the last check; a new value clears it. */
const rejected = new Set<string>();

function change(name: string): void {
known.delete(name);
rejected.delete(name);
changed.set(name, performance.now());
}

/** Records a provider's verdict on `name`'s current value; true when that changes what Settings shows. */
export function judge(name: string, accepted: boolean): boolean {
if (accepted !== rejected.has(name)) return false;
if (accepted) rejected.delete(name);
else rejected.add(name);
return true;
}

export const isRejected = (name: string) => rejected.has(name);

export async function store(name: string, value: string): Promise<void> {
await Bun.secrets.set({ service: config.keychain, name, value });
change(name);
Expand Down
7 changes: 6 additions & 1 deletion apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,12 @@ export type CredentialStatus = {
waiting?: string;
};

export type ProviderSettings = CredentialStatus & { id: ProviderId; name: string };
export type ProviderSettings = CredentialStatus & {
id: ProviderId;
name: string;
/** The provider rejected the key at its last check, and the key hasn't changed since. */
rejected?: true;
};

export type Settings = {
providers: ProviderSettings[];
Expand Down
33 changes: 28 additions & 5 deletions apps/host/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@ import { available } from "@ace/channel/models";
import { config } from "./config";
import * as directory from "./directory";
import { rejection } from "./http";
import { forget, key, KEYCHAIN_WAITING, latest, overriding, settled, store } from "./keys";
import {
forget,
isRejected,
judge,
key,
KEYCHAIN_WAITING,
latest,
overriding,
settled,
store,
} from "./keys";
import { models, parseModel } from "./manage";
import { canonical } from "./move";
import {
Expand Down Expand Up @@ -41,7 +51,11 @@ export async function settings(fresh?: boolean): Promise<Settings> {
const [statuses, usable] = await Promise.all([
Promise.all(
[...providers].map(async ([id, { name, key }]) =>
Object.assign(await latest(key, fresh), { id, name })
Object.assign(
await latest(key, fresh),
{ id, name },
isRejected(key) ? { rejected: true as const } : {},
)
),
),
available(catalog.models).catch((error: Error) => error),
Expand All @@ -56,6 +70,8 @@ export async function settings(fresh?: boolean): Promise<Settings> {
};
}

class Rejected extends Error {}

async function check(id: ProviderId, value: string): Promise<void> {
const selected = provider(id);
const headers: Record<string, string> = id === "anthropic"
Expand All @@ -74,7 +90,7 @@ async function check(id: ProviderId, value: string): Promise<void> {
// Provider errors can echo credentials. Only status codes leave the host.
await response.body?.cancel();
if (response.ok) return;
if (response.status === 401) throw new Error(`${selected.name} rejected this API key.`);
if (response.status === 401) throw new Rejected(`${selected.name} rejected this API key.`);
if (response.status === 403) {
throw new Error(
`${selected.name} denied access. Check this key's permissions for listing models.`,
Expand Down Expand Up @@ -105,11 +121,18 @@ export async function setKey(id: ProviderId, value: string): Promise<void> {
}
}

export async function checkKey(id: ProviderId): Promise<void> {
/** `changed` runs when the verdict changes what Settings shows for the key. */
export async function checkKey(id: ProviderId, changed: () => void): Promise<void> {
const selected = provider(id);
const value = await key(selected.key);
if (!value) throw new Error(`Add a ${selected.name} API key first.`);
await check(id, value);
try {
await check(id, value);
} catch (error) {
if (error instanceof Rejected && judge(selected.key, false)) changed();
throw error;
}
if (judge(selected.key, true)) changed();
}

export async function removeKey(id: ProviderId): Promise<void> {
Expand Down