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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ _In development — bullets added per PR; finalized at release._

### ✨ New Features

- **feat(dashboard): inline show/hide toggle for API keys on the API Manager page** — each row in the API keys list now exposes an eye / eye-off button next to the masked key. Clicking it lazy-fetches the full key via the existing `/api/keys/{id}/reveal` endpoint (so the policy gate is unchanged), caches it client-side, and renders the full value inline; clicking again hides it. The toggle only appears when `allowKeyReveal` is true (server policy), so an installation that disables reveal still sees a locked stub. Reuses the existing i18n keys `apiManager.showKey` / `apiManager.hideKey` already shipped in every locale, and clears the cached reveal when the key is deleted. Inspired-by: toanalien.
- **feat(oauth): import accounts from CLIProxyAPI** — Settings → CLIProxyAPI now has an "Import accounts" button that reads the OAuth accounts CLIProxyAPI already saved in `~/.cli-proxy-api/` and imports them as OmniRoute connections, so you don't have to log into every account individually. CLIProxyAPI's unified auth-file format is parsed by `type` discriminator and the supported account types (Gemini, Codex, Claude/Anthropic, Antigravity, Qwen, Kimi) are upserted; unknown types are skipped. The preview never exposes tokens to the client. (thanks @powellnorma)
- **feat(routing): opt-in setting to echo the requested alias/combo name in the response model field** — Settings → Routing now has an "Echo requested model name in responses" toggle (default off). When enabled, the response `model` field (non-streaming and every streamed SSE chunk) reports the alias or combo name the client requested instead of the upstream model name, so strict clients such as Claude Desktop — which reject a response whose `model` does not match the request with a 401 — work with aliases and combos. (thanks @thaiphuong1202)
- **feat(providers): expand the openai and gemini direct registries with first-class variants already known elsewhere** — the `openai` provider entry now exposes `gpt-4.1-mini`, `gpt-4.1-nano`, `o3-mini`, and `o4-mini` (the latter two carry `REASONING_UNSUPPORTED` like `o3`), and the `gemini` entry now exposes `gemini-2.0-flash-lite` and `gemini-3-flash-lite-preview`. These models were already first-class throughout sibling subsystems (cost estimator, task fitness, free-model catalog, multiple aggregator registries) but happened to be missing from the direct openai/gemini namespaces. Embedding/TTS/image-gen models stay in their dedicated registries (`embeddingRegistry.ts`, `audioRegistry.ts`, `imageRegistry.ts`); legacy ids OmniRoute curated out (o1, gpt-4-turbo, …) are not restored. (thanks @East-rayyy)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
computeApiKeyCounts,
formatUsdCost,
toLocalDateTimeInputValue,
maskKey,
toggleKeyVisibility,
} from "./apiManagerPageUtils";
import type { KeyStatus, KeyType } from "./apiManagerPageUtils";
import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage";
Expand Down Expand Up @@ -220,6 +222,10 @@ export default function ApiManagerPageClient() {
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
const [allowKeyReveal, setAllowKeyReveal] = useState(false);
// Per-row API key visibility toggle (eye / eye-off). Keys default to masked.
// Map id -> fully revealed key string fetched on demand from /api/keys/{id}/reveal.
const [revealedKeys, setRevealedKeys] = useState<Map<string, string>>(new Map());
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
const createKeyNameFieldRef = useRef<HTMLDivElement | null>(null);

const [searchQuery, setSearchQuery] = useState("");
Expand Down Expand Up @@ -570,6 +576,14 @@ export default function ApiManagerPageClient() {
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { method: "DELETE" });
if (res.ok) {
setKeys((prev) => prev.filter((k) => k.id !== id));
// Clean up any cached reveal/visibility state for this key.
setRevealedKeys((prev) => {
if (!prev.has(id)) return prev;
const next = new Map(prev);
next.delete(id);
return next;
});
setVisibleKeys((prev) => (prev.has(id) ? toggleKeyVisibility(prev, id) : prev));
} else {
const data = await res.json();
setPageError(data.error || t("failedDeleteKey"));
Expand Down Expand Up @@ -624,13 +638,52 @@ export default function ApiManagerPageClient() {

const data = await res.json();
if (typeof data?.key === "string") {
// Cache the revealed value so a subsequent show-toggle does not refetch.
setRevealedKeys((prev) => {
const next = new Map(prev);
next.set(keyId, data.key);
return next;
});
await copy(data.key, `existing_key_${keyId}`);
}
} catch (error) {
console.log("Error copying existing key:", error);
}
};

/**
* Toggle the visibility of one key inline (eye / eye-off button).
* Lazy-fetches the full key from /api/keys/{id}/reveal on the FIRST show,
* then caches it in `revealedKeys` so re-toggling is instant. Hiding only
* flips the visibility set — the cached reveal stays so a re-show is free.
*/
const handleToggleKeyVisibility = async (keyId: string) => {
if (!keyId) return;
const isCurrentlyVisible = visibleKeys.has(keyId);

if (!isCurrentlyVisible && !revealedKeys.has(keyId)) {
try {
const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`);
if (!res.ok) {
console.log("Error revealing key:", await res.text());
return;
}
const data = await res.json();
if (typeof data?.key !== "string") return;
setRevealedKeys((prev) => {
const next = new Map(prev);
next.set(keyId, data.key);
return next;
});
} catch (error) {
console.log("Error revealing key:", error);
return;
}
}

setVisibleKeys((prev) => toggleKeyVisibility(prev, keyId));
};

const handleUpdatePermissions = async (
name: string,
allowedModels: string[],
Expand Down Expand Up @@ -931,18 +984,35 @@ export default function ApiManagerPageClient() {
</span>
</div>
<div className="col-span-3 flex items-center gap-1.5">
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
<code className="text-sm text-text-muted font-mono truncate">
{visibleKeys.has(key.id)
? (revealedKeys.get(key.id) ?? key.key)
: maskKey(key.key)}
</code>
{allowKeyReveal ? (
<button
onClick={() => handleCopyExistingKey(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={tc("copy")}
aria-label={tc("copy")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `existing_key_${key.id}` ? "check" : "content_copy"}
</span>
</button>
<>
<button
onClick={() => handleToggleKeyVisibility(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={visibleKeys.has(key.id) ? t("hideKey") : t("showKey")}
aria-label={visibleKeys.has(key.id) ? t("hideKey") : t("showKey")}
aria-pressed={visibleKeys.has(key.id)}
>
<span className="material-symbols-outlined text-[14px]">
{visibleKeys.has(key.id) ? "visibility_off" : "visibility"}
</span>
</button>
<button
onClick={() => handleCopyExistingKey(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={tc("copy")}
aria-label={tc("copy")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `existing_key_${key.id}` ? "check" : "content_copy"}
</span>
</button>
Comment on lines +1005 to +1014

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the API key is already revealed and cached in revealedKeys, we can copy it directly from the client-side cache instead of making an unnecessary network request to /api/keys/{id}/reveal.

                        <button
                          onClick={async () => {
                            const cached = revealedKeys.get(key.id);
                            if (cached) {
                              await copy(cached, `existing_key_${key.id}`);
                            } else {
                              await handleCopyExistingKey(key.id);
                            }
                          }}
                          className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
                          title={tc("copy")}
                          aria-label={tc("copy")}
                        >
                          <span className="material-symbols-outlined text-[14px]">
                            {copied === `existing_key_${key.id}` ? "check" : "content_copy"}
                          </span>
                        </button>

</>
) : (
<span
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,24 @@ export function formatUsdCost(value: number, locale: string): string {
maximumFractionDigits: amount > 0 && amount < 1 ? 4 : 2,
}).format(amount);
}

/**
* Mask a fully revealed API key for the at-rest display: keep the first 8 chars
* (provider prefix + a few entropy bits, e.g. `sk-or-12...`), append an ellipsis.
* Returns "" for empty/missing input so the UI can render an empty `<code>` cleanly.
*/
export function maskKey(fullKey: string | null | undefined): string {
if (!fullKey) return "";
return fullKey.length > 8 ? `${fullKey.slice(0, 8)}...` : fullKey;
}

/**
* Immutable Set toggle helper for the "which keys are currently revealed" state.
* Returns a NEW Set so React state setters always see a fresh reference.
*/
export function toggleKeyVisibility(prev: Set<string>, keyId: string): Set<string> {
const next = new Set(prev);
if (next.has(keyId)) next.delete(keyId);
else next.add(keyId);
return next;
}
51 changes: 51 additions & 0 deletions tests/unit/api-manager-key-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
maskKey,
toggleKeyVisibility,
} from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js";

describe("maskKey", () => {
it("returns empty string when key is missing or empty", () => {
assert.equal(maskKey(""), "");
assert.equal(maskKey(null), "");
assert.equal(maskKey(undefined), "");
});

it("returns the key untouched when it fits the visible budget (<=8 chars)", () => {
assert.equal(maskKey("sk"), "sk");
assert.equal(maskKey("sk-12345"), "sk-12345");
});

it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => {
const full = "sk-or-1234567890abcdef";
const masked = maskKey(full);
assert.equal(masked.startsWith("sk-or-12"), true);
assert.equal(masked.endsWith("..."), true);
// Must not leak the tail
assert.equal(masked.includes("90abcdef"), false);
});
});

describe("toggleKeyVisibility", () => {
it("adds an id when it is not present", () => {
const next = toggleKeyVisibility(new Set<string>(), "k1");
assert.equal(next.has("k1"), true);
assert.equal(next.size, 1);
});

it("removes an id when it is already present", () => {
const next = toggleKeyVisibility(new Set<string>(["k1", "k2"]), "k1");
assert.equal(next.has("k1"), false);
assert.equal(next.has("k2"), true);
assert.equal(next.size, 1);
});

it("returns a NEW Set (does not mutate the input — React state safety)", () => {
const input = new Set<string>(["k1"]);
const output = toggleKeyVisibility(input, "k2");
assert.notEqual(output, input);
assert.equal(input.has("k2"), false);
assert.equal(output.has("k2"), true);
});
});
Comment on lines +1 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the project uses Vitest (as indicated by vitest.config.ts in the root), it is highly recommended to use Vitest's native APIs (describe, it, expect) instead of Node's built-in node:test and node:assert. This ensures consistent test execution, reporting, and coverage metrics across the entire test suite.

Here is how the test file can be refactored to use Vitest:

import { describe, it, expect } from "vitest";
import {
  maskKey,
  toggleKeyVisibility,
} from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js";

describe("maskKey", () => {
  it("returns empty string when key is missing or empty", () => {
    expect(maskKey("")).toBe("");
    expect(maskKey(null)).toBe("");
    expect(maskKey(undefined)).toBe("");
  });

  it("returns the key untouched when it fits the visible budget (<=8 chars)", () => {
    expect(maskKey("sk")).toBe("sk");
    expect(maskKey("sk-12345")).toBe("sk-12345");
  });

  it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => {
    const full = "sk-or-1234567890abcdef";
    const masked = maskKey(full);
    expect(masked.startsWith("sk-or-12")).toBe(true);
    expect(masked.endsWith("...")).toBe(true);
    expect(masked.includes("90abcdef")).toBe(false);
  });
});

describe("toggleKeyVisibility", () => {
  it("adds an id when it is not present", () => {
    const next = toggleKeyVisibility(new Set<string>(), "k1");
    expect(next.has("k1")).toBe(true);
    expect(next.size).toBe(1);
  });

  it("removes an id when it is already present", () => {
    const next = toggleKeyVisibility(new Set<string>(["k1", "k2"]), "k1");
    expect(next.has("k1")).toBe(false);
    expect(next.has("k2")).toBe(true);
    expect(next.size).toBe(1);
  });

  it("returns a NEW Set (does not mutate the input — React state safety)", () => {
    const input = new Set<string>(["k1"]);
    const output = toggleKeyVisibility(input, "k2");
    expect(output).not.toBe(input);
    expect(input.has("k2")).toBe(false);
    expect(output.has("k2")).toBe(true);
  });
});

Loading