summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/model-select.ts
blob: bebad2cb93393d0f5f859dbeedb878581ecacfa4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import type { ModelMetadata } from "@dispatch/transport-contract";

/**
 * Pure helpers for the two-step model picker.
 *
 * Models arrive from `GET /models` as `<key>/<model>` strings, where `key` is
 * the credential name (the part before the FIRST slash) and `model` is the rest.
 * These pure functions split that into a key selector + a model selector and
 * recombine the choice — zero DOM, zero Svelte.
 */

export interface SplitModel {
  readonly key: string;
  readonly model: string;
}

/** Split `<key>/<model>` on the first slash. A slashless name is all-key. */
export function splitModelName(full: string): SplitModel {
  const i = full.indexOf("/");
  if (i === -1) return { key: full, model: "" };
  return { key: full.slice(0, i), model: full.slice(i + 1) };
}

/** Recombine a key + model into a `<key>/<model>` name (key-only if no model). */
export function joinModelName(key: string, model: string): string {
  return model === "" ? key : `${key}/${model}`;
}

/** Distinct keys across all models, in first-seen order. */
export function modelKeys(models: readonly string[]): string[] {
  const seen = new Set<string>();
  const openCodeKeys: Array<{ key: string; n: number }> = [];
  const otherKeys: string[] = [];
  for (const full of models) {
    const { key } = splitModelName(full);
    if (seen.has(key)) continue;
    seen.add(key);
    const n = openCodeNumber(key);
    if (n !== null) openCodeKeys.push({ key, n });
    else otherKeys.push(key);
  }
  // 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. */
export function modelsForKey(models: readonly string[], key: string): string[] {
  const out: string[] = [];
  for (const full of models) {
    const split = splitModelName(full);
    if (split.key === key) out.push(split.model);
  }
  return out;
}

/**
 * Whether a given full model name (`<key>/<model>`) is vision-capable — i.e.
 * `GET /models` `modelInfo[name].vision === true`. Absent/`false`/unknown →
 * `false` (the server's vision handoff transcribes images to text for it).
 * Pure lookup against the catalog metadata; zero DOM.
 */
export function isVisionModel(
  modelInfo: Readonly<Record<string, ModelMetadata>>,
  fullName: string,
): 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}`;
}