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