diff --git a/packages/web/src/features/chat/chat-input.tsx b/packages/web/src/features/chat/chat-input.tsx
index 2364ac0..f43760b 100644
--- a/packages/web/src/features/chat/chat-input.tsx
+++ b/packages/web/src/features/chat/chat-input.tsx
@@ -7,7 +7,8 @@
* indicator) + Model + send (up arrow);
* In draft state (Session not yet created), when models/onChangeModel are supplied, the model
* selector sits to the left of the send button (provider logo + name, popup opens **downward**,
- * with a top quick-search box, and an internal scroll cap to avoid overflowing the screen) — once
+ * with a top quick-search box, an internal scroll cap to avoid overflowing the screen, and a
+ * configured-key-first list with a bottom "show all" row — see ModelSelect) — once
* the Session is created the model is locked, and the same spot switches to a read-only
* logo + name display;
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
@@ -56,7 +57,7 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
import { SkillIcon } from "../skills/skill-icon-view";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { ProviderLogo } from "../../components/ui/provider-logo";
-import { matchesQuery, orderModelsLikeLibrary, sameModelRef } from "../models/model-grouping";
+import { hasConfiguredKey, sameModelRef, visibleChatModels } from "../models/model-grouping";
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
import { matchSlash, removeSlashToken } from "./slash-token";
import {
@@ -206,6 +207,13 @@ function modelLabel(m: ModelInfo): string {
return m.displayName ?? m.modelId;
}
+/**
+ * "No key" marker for the model dropdown's key-less rows: a key struck through by a prohibition
+ * slash (24x24 line art, grayscale via currentColor, matching the approval-mode icon style).
+ */
+const NO_KEY_ICON =
+ "M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4M2 2l20 20";
+
/**
* Model selector (draft state only; docked to the left of the send button): both the button and
* candidate items show the provider logo. The menu opens **downward** — the draft card is
@@ -214,6 +222,12 @@ function modelLabel(m: ModelInfo): string {
* scroll (max-h-56) so it never overflows the browser's viewport height no matter how many models
* there are. On narrow screens only the logo remains (name hidden); list items mark the project
* default.
+ * By default only models with a configured API key are listed (stored masked key — the same
+ * standard as the model page's key status; `envKey` is merely the NAME of a fallback env var and
+ * doesn't count), with the selected and the default model always visible even without a key; a
+ * muted bottom row reveals the remaining key-less models (marked by a struck-through key icon,
+ * with the "no key" text in its title) without closing the menu or changing the selection. When
+ * no model has a key at all, everything is listed directly.
*/
function ModelSelect({
models,
@@ -231,12 +245,21 @@ function ModelSelect({
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
+ // Expanded "show all" state: collapses back to key-configured models on each open.
+ const [showAll, setShowAll] = useState(false);
const current = models.find((m) => sameModelRef(m, value));
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
- // (user-defined groups after, custom last), in-group order preserved.
- const filtered = orderModelsLikeLibrary(models).filter((m) => matchesQuery(m, query));
+ // (user-defined groups after, custom last), in-group order preserved. By default the list
+ // keeps only key-configured models (selected/default always included; lists everything when
+ // no model has a key); the query filters what's visible.
+ const visible = visibleChatModels(models, { showAll, query, selected: value, defaultModel });
+ // How many models the key filter hides under the current query (0 when expanded): drives the bottom "show all" row.
+ const hiddenCount = showAll
+ ? 0
+ : visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length -
+ visible.length;
return (
{
const next = !open;
setOpen(next);
- if (next) setQuery(""); // Always start from the full list each time it opens
+ if (next) {
+ // Each open starts from the unsearched, collapsed (configured-only) list.
+ setQuery("");
+ setShowAll(false);
+ }
}}
className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
@@ -292,10 +319,10 @@ function ModelSelect({
/>
+ {/* Bottom expander row (pinned below the scroll area, mirroring the search box on top):
+ reveals the models hidden by the configured-key filter in place — the menu stays open
+ and the selection is untouched. */}
+ {hiddenCount > 0 && (
+
+
+
+ )}
);
}
diff --git a/packages/web/src/features/models/model-grouping.ts b/packages/web/src/features/models/model-grouping.ts
index 344b5fe..a7b93ef 100644
--- a/packages/web/src/features/models/model-grouping.ts
+++ b/packages/web/src/features/models/model-grouping.ts
@@ -98,3 +98,48 @@ export function groupModelRows(
export function orderModelsLikeLibrary(rows: T[]): T[] {
return groupModelRows(rows, "").flatMap((g) => g.rows);
}
+
+/** Row shape for the configured-key filter: adds the read-only credential display (the DTO's ModelInfo is a superset). */
+export interface ModelCredentialRowLike extends ModelRowLike {
+ credential?: { apiKeyMasked?: string };
+}
+
+/**
+ * Whether the model has an API key configured: judged solely by the stored (masked) key — the
+ * same standard as the chat credential guide and the model page's key status. `envKey` is only
+ * the NAME of a fallback environment variable; its presence says nothing about whether that
+ * variable is actually set, so it never counts as configured.
+ */
+export function hasConfiguredKey(m: ModelCredentialRowLike): boolean {
+ return !!m.credential?.apiKeyMasked;
+}
+
+export interface VisibleChatModelsOptions {
+ /** true = list every model (the dropdown's expanded "show all" state). */
+ showAll: boolean;
+ query: string;
+ /** Currently selected model: always visible even without a configured key (the active choice must never be invisible). */
+ selected?: ModelRefValue | null;
+ /** Project default model: always visible even without a configured key. */
+ defaultModel?: ModelRefValue | null;
+}
+
+/**
+ * Candidate list for the chat model dropdown: library order → keep only key-configured models
+ * (plus the selected and the default model, unless showAll) → the query then filters whatever
+ * is visible. When NO model has a configured key, the key filter degrades to showAll
+ * (everything listed), so the dropdown is never uselessly empty.
+ */
+export function visibleChatModels(
+ models: T[],
+ { showAll, query, selected, defaultModel }: VisibleChatModelsOptions,
+): T[] {
+ const ordered = orderModelsLikeLibrary(models);
+ const keep =
+ showAll || !ordered.some(hasConfiguredKey)
+ ? ordered
+ : ordered.filter(
+ (m) => hasConfiguredKey(m) || sameModelRef(m, selected) || sameModelRef(m, defaultModel),
+ );
+ return keep.filter((m) => matchesQuery(m, query));
+}
diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts
index e155429..df18073 100644
--- a/packages/web/src/lib/strings-en.ts
+++ b/packages/web/src/lib/strings-en.ts
@@ -382,6 +382,8 @@ export const en: Strings = {
readOnlyHint: "Members have read-only access; only owners can change models and credentials",
empty: "No models configured yet",
noKey: "No key",
+ showModelsWithoutKey: (n: number): string =>
+ `Show model${n === 1 ? "" : "s"} without a key (${n})`,
pendingSave: "(pending save)",
modelIdExists: "This model id already exists",
pricingAllOrNone: "Fill all three prices",
diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts
index e1d564e..740f90b 100644
--- a/packages/web/src/lib/strings.ts
+++ b/packages/web/src/lib/strings.ts
@@ -359,6 +359,8 @@ export const zh = {
readOnlyHint: "member 只读;模型与 credential 修改仅 owner 可执行",
empty: "尚未配置任何模型",
noKey: "未配置 key",
+ /** Chat model dropdown's bottom expander row: reveals the models hidden by the configured-key filter. */
+ showModelsWithoutKey: (n: number): string => `显示未配置 key 的模型(${n} 个)`,
pendingSave: "(待保存)",
modelIdExists: "该模型 id 已存在",
pricingAllOrNone: "三项价格需一并填写",
diff --git a/packages/web/test/model-grouping.test.ts b/packages/web/test/model-grouping.test.ts
index 652f1ab..d3efc23 100644
--- a/packages/web/test/model-grouping.test.ts
+++ b/packages/web/test/model-grouping.test.ts
@@ -6,16 +6,20 @@
* provider not in the catalog becomes a custom-built group — each forms its own
* group, sorted by name and appended after custom; empty groups are hidden, except the
* custom group, which is always shown when there's no search query, hosting the generic
- * "add model" entry point.
+ * "add model" entry point. Also covers the chat dropdown's visibility rule (visibleChatModels):
+ * key-configured models only by default (a stored masked key, judged by hasConfiguredKey),
+ * selected/default always visible, everything listed when nothing is configured or on showAll.
*/
import { describe, expect, it } from "vitest";
import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
import {
groupModelRows,
- orderModelsLikeLibrary,
+ hasConfiguredKey,
matchesQuery,
+ orderModelsLikeLibrary,
+ visibleChatModels,
} from "../src/features/models/model-grouping";
-import type { ModelRowLike } from "../src/features/models/model-grouping";
+import type { ModelCredentialRowLike, ModelRowLike } from "../src/features/models/model-grouping";
const rows: ModelRowLike[] = [
{ provider: "anthropic", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
@@ -131,6 +135,101 @@ describe("groupModelRows", () => {
});
});
+describe("hasConfiguredKey", () => {
+ it("only a stored (masked) key counts as configured", () => {
+ expect(
+ hasConfiguredKey({
+ provider: "anthropic",
+ modelId: "m",
+ credential: { apiKeyMasked: "sk-a***xyz" },
+ }),
+ ).toBe(true);
+ expect(hasConfiguredKey({ provider: "anthropic", modelId: "m" })).toBe(false);
+ expect(hasConfiguredKey({ provider: "anthropic", modelId: "m", credential: {} })).toBe(false);
+ // envKey is merely the NAME of a fallback env var (nothing says the var is actually set): never counts.
+ const envOnly = { provider: "anthropic", modelId: "m", envKey: "ANTHROPIC_API_KEY" };
+ expect(hasConfiguredKey(envOnly)).toBe(false);
+ });
+});
+
+describe("visibleChatModels", () => {
+ const configured = (provider: string, modelId: string): ModelCredentialRowLike => ({
+ provider,
+ modelId,
+ credential: { apiKeyMasked: "sk-***" },
+ });
+ const keyless = (provider: string, modelId: string): ModelCredentialRowLike => ({
+ provider,
+ modelId,
+ });
+ const pool: ModelCredentialRowLike[] = [
+ keyless("deepseek", "deepseek-v4"),
+ configured("anthropic", "claude-sonnet-4-6"),
+ keyless("anthropic", "claude-opus-4-8"),
+ configured("moonshot", "kimi-k2.6"),
+ keyless("custom", "my-proxy"),
+ ];
+
+ it("by default lists only key-configured models, in library order", () => {
+ expect(visibleChatModels(pool, { showAll: false, query: "" }).map((m) => m.modelId)).toEqual([
+ "claude-sonnet-4-6",
+ "kimi-k2.6",
+ ]);
+ });
+
+ it("showAll lists everything, still in library order", () => {
+ expect(visibleChatModels(pool, { showAll: true, query: "" }).map((m) => m.modelId)).toEqual([
+ "deepseek-v4",
+ "claude-sonnet-4-6",
+ "claude-opus-4-8",
+ "kimi-k2.6",
+ "my-proxy",
+ ]);
+ });
+
+ it("the selected and the default model stay visible even without a key", () => {
+ const visible = visibleChatModels(pool, {
+ showAll: false,
+ query: "",
+ selected: { provider: "anthropic", modelId: "claude-opus-4-8" },
+ defaultModel: { provider: "deepseek", modelId: "deepseek-v4" },
+ });
+ expect(visible.map((m) => m.modelId)).toEqual([
+ "deepseek-v4", // default, key-less — kept
+ "claude-sonnet-4-6",
+ "claude-opus-4-8", // selected, key-less — kept
+ "kimi-k2.6",
+ ]);
+ });
+
+ it("when no model has a configured key, everything is listed (never an empty dropdown)", () => {
+ const none = [keyless("anthropic", "a"), keyless("moonshot", "b")];
+ expect(visibleChatModels(none, { showAll: false, query: "" }).map((m) => m.modelId)).toEqual([
+ "a",
+ "b",
+ ]);
+ });
+
+ it("the query filters what's visible: hidden key-less models only match once showAll", () => {
+ expect(visibleChatModels(pool, { showAll: false, query: "opus" })).toEqual([]);
+ expect(visibleChatModels(pool, { showAll: true, query: "opus" }).map((m) => m.modelId)).toEqual(
+ ["claude-opus-4-8"],
+ );
+ // The query also narrows the configured-only view.
+ expect(
+ visibleChatModels(pool, { showAll: false, query: "kimi" }).map((m) => m.modelId),
+ ).toEqual(["kimi-k2.6"]);
+ // ...and a key-less selected model kept by the exception is still searchable.
+ expect(
+ visibleChatModels(pool, {
+ showAll: false,
+ query: "opus",
+ selected: { provider: "anthropic", modelId: "claude-opus-4-8" },
+ }).map((m) => m.modelId),
+ ).toEqual(["claude-opus-4-8"]);
+ });
+});
+
describe("orderModelsLikeLibrary", () => {
it("flattens to the library page's order: built-in provider order, user groups after, custom last", () => {
const rows: ModelRowLike[] = [