| 1 | import type { ProviderModelOverrideView } from "./types"; |
| 2 | |
| 3 | export function mergedFetchedProviderModels(current: string[], fetched: string[], options: { preserveCurated?: boolean } = {}): string[] { |
| 4 | const saved = uniqueStrings(current); |
| 5 | if (options.preserveCurated && saved.length > 0) return saved; |
| 6 | return uniqueStrings([...saved, ...fetched]); |
| 7 | } |
| 8 | |
| 9 | export function providerModelCandidates(current: string[], fetched: string[]): string[] { |
| 10 | return uniqueStrings([...current, ...fetched]).filter(isLikelyChatModel); |
| 11 | } |
| 12 | |
| 13 | export function inferredVisionModels(models: string[]): string[] { |
| 14 | return uniqueStrings(models).filter((model) => isLikelyChatModel(model) && isLikelyVisionModel(model)); |
| 15 | } |
| 16 | |
| 17 | export function providerDefaultModel(currentDefault: string, models: string[]): string { |
| 18 | return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? ""; |
| 19 | } |
| 20 | |
| 21 | export function providerModelContextWindowDrafts(overrides: ProviderModelOverrideView[] | null | undefined): Record<string, string> { |
| 22 | const drafts: Record<string, string> = {}; |
| 23 | for (const override of overrides ?? []) { |
| 24 | const model = override.model.trim(); |
| 25 | const contextWindow = normalizedContextWindow(override.contextWindow); |
| 26 | if (model && contextWindow > 0) drafts[model] = String(contextWindow); |
| 27 | } |
| 28 | return drafts; |
| 29 | } |
| 30 | |
| 31 | export function providerModelContextWindowIsSmall(value: unknown): boolean { |
| 32 | const contextWindow = normalizedContextWindow(value); |
| 33 | return contextWindow > 0 && contextWindow < 16_384; |
| 34 | } |
| 35 | |
| 36 | export function mergeProviderModelContextWindows( |
| 37 | overrides: ProviderModelOverrideView[] | null | undefined, |
| 38 | models: string[], |
| 39 | drafts: Record<string, string>, |
| 40 | ): ProviderModelOverrideView[] { |
| 41 | const existing = new Map((overrides ?? []).map((override) => [override.model.trim(), override])); |
| 42 | const merged: ProviderModelOverrideView[] = []; |
| 43 | for (const model of uniqueStrings(models)) { |
| 44 | const previous = existing.get(model); |
| 45 | const parsedContextWindow = normalizedContextWindow(drafts[model]); |
| 46 | const override: ProviderModelOverrideView = { |
| 47 | model, |
| 48 | reasoningProtocol: previous?.reasoningProtocol ?? "", |
| 49 | supportedEfforts: previous?.supportedEfforts ?? [], |
| 50 | defaultEffort: previous?.defaultEffort ?? "", |
| 51 | vision: previous?.vision ?? null, |
| 52 | contextWindow: Math.max(parsedContextWindow, 0), |
| 53 | }; |
| 54 | if ( |
| 55 | override.reasoningProtocol.trim() |
| 56 | || override.supportedEfforts.length > 0 |
| 57 | || override.defaultEffort.trim() |
| 58 | || override.vision != null |
| 59 | || (override.contextWindow ?? 0) > 0 |
| 60 | ) { |
| 61 | merged.push(override); |
| 62 | } |
| 63 | } |
| 64 | return merged; |
| 65 | } |
| 66 | |
| 67 | function normalizedContextWindow(value: unknown): number { |
| 68 | const parsed = Number(value); |
| 69 | if (!Number.isFinite(parsed) || parsed <= 0) return 0; |
| 70 | return Math.min(Math.trunc(parsed), Number.MAX_SAFE_INTEGER); |
| 71 | } |
| 72 | |
| 73 | export function providerRequiresKey(provider: { requiresKey?: boolean; apiKeyEnv?: string }): boolean { |
| 74 | if (typeof provider.requiresKey === "boolean") return provider.requiresKey; |
| 75 | return Boolean((provider.apiKeyEnv ?? "").trim()); |
| 76 | } |
| 77 | |
| 78 | export function providerIsConfigured(provider: { configured?: boolean; requiresKey?: boolean; apiKeyEnv?: string; keySet?: boolean }): boolean { |
| 79 | if (typeof provider.configured === "boolean") return provider.configured; |
| 80 | return !providerRequiresKey(provider) || Boolean(provider.keySet); |
| 81 | } |
| 82 | |
| 83 | export function providerApiKeyEnvForSave(name: string, apiKeyEnv: string, keyDraft: string): string { |
| 84 | const explicit = apiKeyEnv.trim(); |
| 85 | if (explicit) return explicit; |
| 86 | return keyDraft.trim() ? apiKeyEnvFromProviderName(name) : ""; |
| 87 | } |
| 88 | |
| 89 | export function apiKeyEnvFromProviderName(name: string): string { |
| 90 | const stem = name |
| 91 | .trim() |
| 92 | .toUpperCase() |
| 93 | .replace(/[^A-Z0-9]+/g, "_") |
| 94 | .replace(/^_+|_+$/g, ""); |
| 95 | if (stem) { |
| 96 | // Dotenv/environment variable names cannot start with a digit. Keep the |
| 97 | // readable name while giving digit-leading providers (for example |
| 98 | // "9router") a valid, stable credential slot. |
| 99 | const validStem = /^[0-9]/.test(stem) ? `CUSTOM_${stem}` : stem; |
| 100 | return `${validStem}_API_KEY`; |
| 101 | } |
| 102 | // When the provider name is entirely non-ASCII (e.g. Chinese characters), |
| 103 | // generate a stable hash suffix so each custom provider gets a unique slot. |
| 104 | const hash = fnv1a32(name.trim()); |
| 105 | return `CUSTOM_${hash}_API_KEY`; |
| 106 | } |
| 107 | |
| 108 | /** 32-bit FNV-1a hash, returns 8-char lowercase hex. Stable and deterministic. */ |
| 109 | function fnv1a32(s: string): string { |
| 110 | let hash = 0x811c9dc5 >>> 0; |
| 111 | for (let i = 0; i < s.length; i++) { |
| 112 | hash ^= s.charCodeAt(i); |
| 113 | hash = Math.imul(hash, 0x01000193) >>> 0; |
| 114 | } |
| 115 | return hash.toString(16).padStart(8, "0"); |
| 116 | } |
| 117 | |
| 118 | export function isLikelyChatModel(model: string): boolean { |
| 119 | const lower = model.trim().toLowerCase(); |
| 120 | if (!lower) return false; |
| 121 | for (const term of ["text-embedding", "text-to-speech", "speech-to-text"]) { |
| 122 | if (lower.includes(term)) return false; |
| 123 | } |
| 124 | const nonChatTokens = new Set([ |
| 125 | "asr", |
| 126 | "stt", |
| 127 | "tts", |
| 128 | "whisper", |
| 129 | "embedding", |
| 130 | "moderation", |
| 131 | "rerank", |
| 132 | "dall", |
| 133 | "transcription", |
| 134 | ]); |
| 135 | return !lower.split(/[-_./:]+/).some((token) => nonChatTokens.has(token)); |
| 136 | } |
| 137 | |
| 138 | export function isLikelyVisionModel(model: string): boolean { |
| 139 | const lower = model.trim().toLowerCase(); |
| 140 | if (!lower) return false; |
| 141 | if (lower === "mimo-v2.5" || lower === "mimo-v2-omni") return true; |
| 142 | const tokens = lower.split(/[-_./:]+/); |
| 143 | if (tokens.includes("audio")) return false; |
| 144 | if (lower.startsWith("gpt-4o")) return true; |
| 145 | const visionTokens = new Set(["vl", "vision", "visual", "multimodal", "omni"]); |
| 146 | return tokens.some((token) => visionTokens.has(token)); |
| 147 | } |
| 148 | |
| 149 | function uniqueStrings(values: string[]): string[] { |
| 150 | const seen = new Set<string>(); |
| 151 | const out: string[] = []; |
| 152 | for (const value of values) { |
| 153 | const model = value.trim(); |
| 154 | if (!model || seen.has(model)) continue; |
| 155 | seen.add(model); |
| 156 | out.push(model); |
| 157 | } |
| 158 | return out; |
| 159 | } |
| 160 |