File size: 10,188 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
// Re-export from open-sse with localDb integration
import {
  getModelAliases,
  getComboByName,
  getComboById,
  getComboByNameInsensitive,
  getProviderNodes,
  getCustomModels,
} from "@/lib/localDb";
import { getCachedSettings } from "@/lib/localDb";
import { getComboStepTarget } from "@/lib/combos/steps";
import {
  parseModel,
  resolveModelAliasFromMap,
  getModelInfoCore,
} from "@omniroute/open-sse/services/model.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";

export { parseModel };

/**
 * Reserved provider prefixes β€” built-in provider ids + aliases. User-defined
 * compatible-node prefixes must not be allowed to shadow these, otherwise a
 * node with prefix="cf" would hijack cloudflare-ai requests (and similar for
 * every built-in provider). Ported from upstream 9router 047fdc89.
 *
 * Built lazily so the registry is only walked once per process.
 */
let _reservedProviderPrefixes: Set<string> | null = null;
function getReservedProviderPrefixes(): Set<string> {
  if (_reservedProviderPrefixes) return _reservedProviderPrefixes;
  const reserved = new Set<string>();
  for (const entry of Object.values(REGISTRY)) {
    if (entry?.id) reserved.add(entry.id);
    if (entry?.alias) reserved.add(entry.alias);
  }
  _reservedProviderPrefixes = reserved;
  return reserved;
}

/**
 * Build a combined model alias map that merges both alias stores:
 * 1. DB-namespace aliases (key_value WHERE namespace='modelAliases') β€” set via
 *    /api/models/alias/ and seeded at startup (e.g. gemini-cli default aliases).
 * 2. Settings-based aliases (settings.modelAliases) β€” set via the Settings UI and
 *    /api/settings/model-aliases/ (stored as a JSON blob in namespace='settings').
 *
 * Settings-based aliases take priority so that UI configuration always wins.
 * Without this merge, aliases configured via the Settings UI were never consulted
 * during provider routing, causing provider inference (e.g. /^gpt-/ β†’ openai) to
 * silently override them (issue #2618 / #2208).
 */
async function getCombinedModelAliases(): Promise<Record<string, unknown>> {
  const [dbAliases, settings] = await Promise.all([
    getModelAliases().catch(() => ({})),
    getCachedSettings().catch(() => ({}) as Record<string, unknown>),
  ]);

  const settingsAliases =
    settings.modelAliases &&
    typeof settings.modelAliases === "object" &&
    !Array.isArray(settings.modelAliases)
      ? (settings.modelAliases as Record<string, unknown>)
      : {};

  // Settings-based aliases win over DB-namespace aliases on key collision
  return { ...dbAliases, ...settingsAliases };
}

/**
 * Resolve model alias from localDb
 */
export async function resolveModelAlias(alias) {
  const aliases = await getModelAliases();
  return resolveModelAliasFromMap(alias, aliases);
}

/**
 * Look up custom-model metadata from the DB in a single read:
 *  - apiFormat: "responses" when the model is configured for the Responses API.
 *  - targetFormat: the optional per-model wire format override (#2905).
 */
async function lookupCustomModelMeta(
  providerId: string,
  modelId: string
): Promise<{ apiFormat?: string; targetFormat?: string }> {
  try {
    const models = await getCustomModels(providerId);
    if (!Array.isArray(models)) return {};
    const match = models.find((m: any) => m.id === modelId);
    if (!match) return {};
    return {
      apiFormat: match.apiFormat === "responses" ? "responses" : undefined,
      targetFormat: typeof match.targetFormat === "string" ? match.targetFormat : undefined,
    };
  } catch {
    return {};
  }
}

/**
 * Get full model info (parse or resolve)
 */
export async function getModelInfo(modelStr) {
  const parsed = parseModel(modelStr);
  const { extendedContext } = parsed;

  const attachCustomApiFormat = async (info: any) => {
    if (!info?.provider || !info?.model) return info;
    const { apiFormat, targetFormat } = await lookupCustomModelMeta(
      String(info.provider),
      String(info.model)
    );
    if (apiFormat || targetFormat) {
      return {
        ...info,
        ...(apiFormat && { apiFormat }),
        ...(targetFormat && { targetFormat }),
      };
    }
    return info;
  };

  // Check custom provider nodes first (for both alias and non-alias formats)
  if (parsed.providerAlias || parsed.provider) {
    // Ensure prefixToCheck is always a concise identifier, not a full model string
    const prefixToCheck = parsed.providerAlias || parsed.provider;

    // Compatible-node prefixes are user-defined. They must not be allowed to
    // shadow built-in provider ids/aliases (e.g. `cf` β†’ cloudflare-ai). When
    // prefixToCheck matches a built-in registry id/alias, skip the compatible-
    // node prefix lookup so the request still routes to the built-in provider.
    // Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...")
    // are never in the reserved set, so the #2778 combo path still works.
    // Ported from upstream 9router 047fdc89.
    const reserved = getReservedProviderPrefixes();
    const isReservedPrefix =
      typeof prefixToCheck === "string" && reserved.has(prefixToCheck);

    if (!isReservedPrefix) {
      // Check OpenAI Compatible nodes
      // Match by node.prefix (user-defined alias) OR node.id (internal UUID id stored by
      // combo steps), so that combo targets using the internal node id still resolve
      // correctly (#2778).
      const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
      const matchedOpenAI = openaiNodes.find(
        (node) => node.prefix === prefixToCheck || node.id === prefixToCheck
      );
      if (matchedOpenAI) {
        const { apiFormat, targetFormat } = await lookupCustomModelMeta(
          matchedOpenAI.id as string,
          parsed.model as string
        );
        return {
          provider: matchedOpenAI.id,
          model: parsed.model,
          extendedContext,
          ...(apiFormat && { apiFormat }),
          ...(targetFormat && { targetFormat }),
        };
      }

      // Check Anthropic Compatible nodes
      const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" });
      const matchedAnthropic = anthropicNodes.find(
        (node) => node.prefix === prefixToCheck || node.id === prefixToCheck
      );
      if (matchedAnthropic) {
        const { apiFormat, targetFormat } = await lookupCustomModelMeta(
          matchedAnthropic.id as string,
          parsed.model as string
        );
        return {
          provider: matchedAnthropic.id,
          model: parsed.model,
          extendedContext,
          ...(apiFormat && { apiFormat }),
          ...(targetFormat && { targetFormat }),
        };
      }
    }

    // stripModelPrefix: if enabled, strip provider prefix and re-resolve
    // the bare model name using existing heuristics (claude-* β†’ anthropic, etc.)
    try {
      const settings = await getCachedSettings();
      if (settings.stripModelPrefix === true) {
        const strippedResult = await getModelInfoCore(parsed.model, getCombinedModelAliases);
        return { ...strippedResult, extendedContext };
      }
    } catch {
      // If settings read fails, fall through to normal resolution
    }
  }

  if (!parsed.isAlias) {
    return await attachCustomApiFormat(await getModelInfoCore(modelStr, null));
  }

  return await attachCustomApiFormat(await getModelInfoCore(modelStr, getCombinedModelAliases));
}

/**
 * Check if model is a combo and return the full combo object
 * @returns {Promise<Object|null>} Full combo object or null if not a combo
 */
export async function getCombo(modelStr) {
  // Try exact match first (supports combos actually named "combo/ANY")
  let combo = await getComboByName(modelStr);
  if (combo && combo.models && combo.models.length > 0) {
    return combo;
  }

  // Fallback: Strip combo/ prefix if present
  if (modelStr.startsWith("combo/")) {
    const nameToSearch = modelStr.substring(6);
    combo = await getComboByName(nameToSearch);
    if (combo && combo.models && combo.models.length > 0) {
      return combo;
    }
  }

  // #4446: the opencode-plugin publishes combos as ModelV2 `id: combo.id`, and
  // the OpenCode `--model` dispatch path forwards a lowercased bare slug. The
  // exact, case-sensitive name match above misses both a combo addressed by its
  // stored id (UUID/slug) and a lowercased display name (e.g. "master-light" for
  // a combo named "MASTER-LIGHT"). These two fallbacks only run after the exact
  // match fails, so they never re-route a combo that already resolves today.
  combo = await getComboById(modelStr);
  if (combo && combo.models && combo.models.length > 0) {
    return combo;
  }

  combo = await getComboByNameInsensitive(modelStr);
  if (combo && combo.models && combo.models.length > 0) {
    return combo;
  }

  return null;
}

/**
 * Check if model matches a combo by name OR by model-combo mapping pattern.
 * This augments getCombo() with glob-based model-to-combo resolution (#563).
 *
 * Resolution order:
 * 1. Exact combo name match (existing behavior)
 * 2. Model-combo mapping pattern match (new β€” glob patterns by priority)
 * 3. null (no combo β€” single-model request)
 */
export async function getComboForModel(modelStr) {
  // 1. Existing behavior β€” exact combo name match
  const combo = await getCombo(modelStr);
  if (combo) return combo;

  // 2. NEW β€” check model-combo mappings table (pattern match)
  try {
    const { resolveComboForModel } = await import("@/lib/localDb");
    const mapped = await resolveComboForModel(modelStr);
    if (mapped && (mapped as any).models?.length > 0) {
      return mapped;
    }
  } catch {
    // If the mappings table doesn't exist yet (pre-migration), continue gracefully
  }

  return null;
}

/**
 * Legacy: get combo models as string array
 * @returns {Promise<string[]|null>}
 */
export async function getComboModels(modelStr) {
  const combo = await getCombo(modelStr);
  if (!combo) return null;
  return (combo.models || [])
    .map((entry) => getComboStepTarget(entry))
    .filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
}