summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/model-select.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat/model-select.ts')
-rw-r--r--src/features/chat/model-select.ts147
1 files changed, 141 insertions, 6 deletions
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
index 602e0ef..bebad2c 100644
--- a/src/features/chat/model-select.ts
+++ b/src/features/chat/model-select.ts
@@ -29,15 +29,80 @@ export function joinModelName(key: string, model: string): string {
/** Distinct keys across all models, in first-seen order. */
export function modelKeys(models: readonly string[]): string[] {
const seen = new Set<string>();
- const out: string[] = [];
+ const openCodeKeys: Array<{ key: string; n: number }> = [];
+ const otherKeys: string[] = [];
for (const full of models) {
const { key } = splitModelName(full);
- if (!seen.has(key)) {
- seen.add(key);
- out.push(key);
- }
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const n = openCodeNumber(key);
+ if (n !== null) openCodeKeys.push({ key, n });
+ else otherKeys.push(key);
}
- return out;
+ // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed
+ // before other providers (the primary credential in this app). Others keep
+ // first-seen order.
+ openCodeKeys.sort((a, b) => a.n - b.n);
+ return [...openCodeKeys.map((e) => e.key), ...otherKeys];
+}
+
+/**
+ * The stable numeric identity of a numbered OpenCode provider id/key
+ * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a
+ * numbered OpenCode key. Numbers are canonical positive integers with no
+ * leading zeros.
+ */
+export function openCodeNumber(key: string): number | null {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(key);
+ return m === null ? null : Number(m[1]);
+}
+
+/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */
+export function isNumberedOpenCode(id: string): boolean {
+ return openCodeNumber(id) !== null;
+}
+
+/**
+ * The credential key of the Claude Code subscription (the external Claude
+ * extension — `auth-claude` + `provider-anthropic`). The backend registers it
+ * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive
+ * as `claude/<model>`.
+ */
+export const CLAUDE_SUBSCRIPTION_KEY = "claude";
+
+/**
+ * True iff `full` is a model served through the Claude Code subscription
+ * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache
+ * expires after 5 minutes). A bare key with no model is not a model.
+ */
+export function isClaudeSubscriptionModel(full: string | undefined): boolean {
+ if (full === undefined) return false;
+ const { key, model } = splitModelName(full);
+ return key === CLAUDE_SUBSCRIPTION_KEY && model !== "";
+}
+
+/**
+ * A friendly human label for a model key/provider id: `opencode-<n>` →
+ * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what
+ * gets selected/persisted — this only affects display.
+ */
+export function modelKeyLabel(key: string): string {
+ const n = openCodeNumber(key);
+ return n === null ? key : `OpenCode #${n}`;
+}
+
+/**
+ * Migration helper: rewrite a legacy `opencode/<model>` full model name to the
+ * numbered `opencode-1/<model>` namespace. Returns the input unchanged for
+ * anything that is not `opencode/`-prefixed (already-numbered names, other
+ * providers). Idempotent. This is the FE-side persisted-state migration; the
+ * backend performs its own equivalent on its own store.
+ */
+export function migrateLegacyModelName(full: string): string {
+ const legacyPrefix = "opencode/";
+ if (!full.startsWith(legacyPrefix)) return full;
+ const model = full.slice(legacyPrefix.length);
+ return model.length === 0 ? full : `opencode-1/${model}`;
}
/** The model suffixes available under a given key, in order. */
@@ -62,3 +127,73 @@ export function isVisionModel(
): boolean {
return modelInfo[fullName]?.vision === true;
}
+
+// ── Named Agents synthetic provider (plan §6) ────────────────────────────────
+//
+// The regular model picker gains a "Named Agents" provider at the TOP of the
+// provider list. Selecting it + a Named Agent name resolves to that agent's raw
+// model for the conversation AND records a snapshot so the selection survives
+// later edits to the shared preset. These helpers are structural (they take a
+// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the
+// named-agents feature up the chain; the picker binds the real types.
+
+/** A minimal Named Agent shape the synthetic provider needs (structural). */
+export interface NamedAgentLike {
+ readonly id: string;
+ readonly name: string;
+ readonly model: string;
+}
+
+/** A minimal per-conversation Named Agent selection snapshot (structural). */
+export interface NamedAgentSnapshotLike {
+ readonly namedAgentId: string;
+ readonly namedAgentName: string;
+ readonly model: string;
+}
+
+/**
+ * The synthetic "Named Agents" provider id used by the regular model picker
+ * (plan §6). Deliberately a value that can never collide with a real provider
+ * (`<credentialName>`), so the provider dropdown treats it as the first option.
+ */
+export const NAMED_AGENTS_PROVIDER = "__named_agents__";
+
+/** The friendly label of the synthetic Named Agents provider. */
+export function namedAgentsProviderLabel(): string {
+ return "Named Agents";
+}
+
+/**
+ * The provider dropdown options for the picker: the synthetic "Named Agents"
+ * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic
+ * entry stays present even with zero Named Agents (plan §6 shows a disabled
+ * "No named agents configured" model option).
+ */
+export function providerKeysWithNamedAgents(models: readonly string[]): string[] {
+ return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)];
+}
+
+/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */
+export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] {
+ return agents.map((a) => a.name);
+}
+
+/**
+ * Whether a full raw model string corresponds to a Named Agent snapshot —
+ * i.e. the conversation was selected via a Named Agent whose captured raw model
+ * equals `selected`. When true, the picker displays `Named Agents -> <name>`.
+ */
+export function isNamedAgentSelection(
+ selected: string,
+ snapshot: NamedAgentSnapshotLike | null,
+): boolean {
+ return snapshot !== null && snapshot.model === selected;
+}
+
+/**
+ * The snapshot label shown in the model dropdown for a Named Agent selection:
+ * "Named Agents -> <name>". Pure display.
+ */
+export function namedAgentSelectionLabel(name: string): string {
+ return `${namedAgentsProviderLabel()} -> ${name}`;
+}