termsearch 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -159,6 +159,97 @@ a:hover { color: var(--link-h); }
159
159
  background: rgba(109,40,217,0.15);
160
160
  }
161
161
 
162
+ .engine-picker {
163
+ margin-left: auto;
164
+ position: relative;
165
+ }
166
+ .engine-picker summary {
167
+ list-style: none;
168
+ }
169
+ .engine-picker summary::-webkit-details-marker {
170
+ display: none;
171
+ }
172
+ .engine-picker-summary {
173
+ display: inline-flex;
174
+ align-items: center;
175
+ gap: 6px;
176
+ padding: 5px 10px;
177
+ border-radius: 999px;
178
+ border: 1px solid var(--border);
179
+ background: var(--bg2);
180
+ color: var(--text2);
181
+ font-size: 11px;
182
+ cursor: pointer;
183
+ }
184
+ .engine-picker-title {
185
+ white-space: nowrap;
186
+ }
187
+ .engine-chevron {
188
+ opacity: 0.8;
189
+ }
190
+ .engine-picker[open] .engine-chevron {
191
+ transform: rotate(180deg);
192
+ }
193
+ .engine-picker-body {
194
+ position: absolute;
195
+ right: 0;
196
+ top: calc(100% + 8px);
197
+ width: min(92vw, 560px);
198
+ max-height: min(65vh, 560px);
199
+ overflow: auto;
200
+ background: var(--bg2);
201
+ border: 1px solid var(--border);
202
+ border-radius: var(--radius);
203
+ padding: 10px;
204
+ z-index: 120;
205
+ box-shadow: 0 16px 32px rgba(0,0,0,0.4);
206
+ }
207
+ .engine-preset-row {
208
+ display: flex;
209
+ gap: 6px;
210
+ margin-bottom: 8px;
211
+ flex-wrap: wrap;
212
+ }
213
+ .engine-group {
214
+ padding: 8px;
215
+ border: 1px solid var(--border2);
216
+ border-radius: var(--radius-sm);
217
+ margin-bottom: 8px;
218
+ }
219
+ .engine-group-title {
220
+ font-size: 10px;
221
+ color: var(--text3);
222
+ margin-bottom: 7px;
223
+ letter-spacing: 0.06em;
224
+ text-transform: uppercase;
225
+ }
226
+ .engine-chip-wrap {
227
+ display: flex;
228
+ flex-wrap: wrap;
229
+ gap: 6px;
230
+ }
231
+ .engine-chip {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: 5px;
235
+ border: 1px solid var(--border);
236
+ border-radius: 999px;
237
+ padding: 4px 8px;
238
+ cursor: pointer;
239
+ font-size: 10px;
240
+ color: var(--text2);
241
+ background: #0d0d0d;
242
+ }
243
+ .engine-chip input {
244
+ margin: 0;
245
+ }
246
+ .engine-actions {
247
+ display: flex;
248
+ gap: 8px;
249
+ justify-content: flex-end;
250
+ margin-top: 8px;
251
+ }
252
+
162
253
  /* ─── Homepage ────────────────────────────────────────────────────────────── */
163
254
  .home {
164
255
  flex: 1;
@@ -192,7 +283,10 @@ a:hover { color: var(--link-h); }
192
283
  letter-spacing: 0.15em;
193
284
  text-transform: uppercase;
194
285
  margin-bottom: 32px;
286
+ text-align: center;
195
287
  }
288
+ .tagline-mobile { display: none; }
289
+ .tagline-desktop { display: inline; }
196
290
 
197
291
  .home-search { width: 100%; max-width: 560px; margin-bottom: 14px; }
198
292
 
@@ -458,8 +552,9 @@ a:hover { color: var(--link-h); }
458
552
 
459
553
  /* AI panel */
460
554
  .panel-ai {
461
- background: var(--ai-bg);
555
+ background: linear-gradient(180deg, rgba(79,70,229,0.12) 0%, rgba(15,15,26,0.95) 42%);
462
556
  border: 1px solid var(--ai-border);
557
+ box-shadow: 0 12px 28px rgba(30,27,75,0.25);
463
558
  }
464
559
  .panel-ai .panel-header-label { color: var(--ai-text2); }
465
560
  .panel-ai .panel-label { color: var(--ai-text); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
@@ -476,6 +571,12 @@ a:hover { color: var(--link-h); }
476
571
  line-height: 1.7;
477
572
  white-space: pre-wrap;
478
573
  margin-top: 10px;
574
+ background: rgba(10,10,16,0.6);
575
+ border: 1px solid rgba(129,140,248,0.22);
576
+ border-radius: var(--radius-sm);
577
+ padding: 10px;
578
+ max-height: 280px;
579
+ overflow: auto;
479
580
  }
480
581
  .ai-meta { font-size: 11px; color: var(--text3); margin-top: 8px; }
481
582
 
@@ -655,6 +756,29 @@ a:hover { color: var(--link-h); }
655
756
  .form-input::placeholder { color: var(--text3); }
656
757
  .form-input[type="password"] { font-family: var(--font-mono); }
657
758
  .form-hint { font-size: 11px; color: var(--text3); margin-top: 3px; }
759
+ .model-quick-list {
760
+ display: flex;
761
+ flex-wrap: wrap;
762
+ gap: 6px;
763
+ margin-top: 8px;
764
+ max-height: 160px;
765
+ overflow: auto;
766
+ padding: 2px 1px 2px 0;
767
+ }
768
+ .model-chip-btn {
769
+ border: 1px solid var(--border);
770
+ border-radius: 999px;
771
+ padding: 4px 8px;
772
+ background: #0b0b0b;
773
+ color: var(--text2);
774
+ font-size: 11px;
775
+ cursor: pointer;
776
+ }
777
+ .model-chip-btn.active {
778
+ color: var(--ai-text2);
779
+ border-color: rgba(109,40,217,0.5);
780
+ background: rgba(109,40,217,0.18);
781
+ }
658
782
 
659
783
  .form-row {
660
784
  display: flex; gap: 8px; align-items: flex-end;
@@ -744,10 +868,16 @@ a:hover { color: var(--link-h); }
744
868
  /* ─── Responsive ──────────────────────────────────────────────────────────── */
745
869
  @media (max-width: 640px) {
746
870
  .header { padding: 8px 12px; gap: 8px; }
747
- .category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; }
871
+ .category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; flex-wrap: wrap; }
748
872
  .cat-tab { font-size: 10px; padding: 4px 8px; }
873
+ .engine-picker { width: 100%; margin-left: 0; }
874
+ .engine-picker-summary { width: 100%; justify-content: space-between; }
875
+ .engine-picker-body { width: calc(100vw - 24px); right: -6px; }
749
876
  .logo-text { font-size: 15px; }
750
877
  .home-logo { font-size: 40px; }
878
+ .home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
879
+ .tagline-desktop { display: none; }
880
+ .tagline-mobile { display: inline; }
751
881
  .result-title { font-size: 15px; }
752
882
  .settings-section { padding: 14px; }
753
883
  .form-row { flex-direction: column; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termsearch",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,6 +56,151 @@ function sleep(ms) {
56
56
  return new Promise((resolve) => setTimeout(resolve, ms));
57
57
  }
58
58
 
59
+ function isAnthropicBase(base) {
60
+ return /anthropic\.com/i.test(base);
61
+ }
62
+
63
+ function normalizeAnthropicBase(base) {
64
+ const clean = String(base || '').replace(/\/$/, '');
65
+ return /\/v1$/i.test(clean) ? clean : `${clean}/v1`;
66
+ }
67
+
68
+ function buildAnthropicHeaders(apiKey) {
69
+ return {
70
+ 'Content-Type': 'application/json',
71
+ 'anthropic-version': '2023-06-01',
72
+ ...(apiKey ? { 'x-api-key': apiKey } : {}),
73
+ };
74
+ }
75
+
76
+ function extractAnthropicText(payload) {
77
+ const parts = Array.isArray(payload?.content) ? payload.content : [];
78
+ return parts
79
+ .map((part) => (part?.type === 'text' ? String(part?.text || '') : ''))
80
+ .join('')
81
+ .trim();
82
+ }
83
+
84
+ async function callAnthropic(prompt, {
85
+ apiBase,
86
+ apiKey,
87
+ model,
88
+ maxTokens = 1200,
89
+ timeoutMs = 90_000,
90
+ systemPrompt = null,
91
+ temperature = 0.3,
92
+ } = {}) {
93
+ if (!apiKey) throw buildAiError('ai_provider_auth', 'ai 401: authentication failed');
94
+ const base = normalizeAnthropicBase(apiBase);
95
+ const body = {
96
+ model,
97
+ max_tokens: maxTokens,
98
+ temperature,
99
+ messages: [{ role: 'user', content: prompt }],
100
+ };
101
+ if (systemPrompt) body.system = systemPrompt;
102
+
103
+ const ac = new AbortController();
104
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
105
+ try {
106
+ const response = await fetch(`${base}/messages`, {
107
+ method: 'POST',
108
+ headers: buildAnthropicHeaders(apiKey),
109
+ body: JSON.stringify(body),
110
+ signal: ac.signal,
111
+ });
112
+ if (response.status === 401 || response.status === 403) {
113
+ throw buildAiError('ai_provider_auth', `ai ${response.status}: authentication failed`);
114
+ }
115
+ if (response.status === 429) {
116
+ throw buildAiError('ai_rate_limited_provider', 'ai 429: rate limit exceeded');
117
+ }
118
+ if (!response.ok) {
119
+ const errBody = await response.text().catch(() => '');
120
+ throw new Error(`ai ${response.status}: ${errBody.slice(0, 220)}`);
121
+ }
122
+ const data = await response.json();
123
+ return {
124
+ content: extractAnthropicText(data),
125
+ reasoning: '',
126
+ model: data?.model || model,
127
+ };
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ }
132
+
133
+ async function streamAnthropic(prompt, onToken, {
134
+ apiBase,
135
+ apiKey,
136
+ model,
137
+ maxTokens = 1200,
138
+ timeoutMs = 90_000,
139
+ systemPrompt = null,
140
+ temperature = 0.3,
141
+ } = {}) {
142
+ if (!apiKey) throw buildAiError('ai_provider_auth', 'ai 401: authentication failed');
143
+ const base = normalizeAnthropicBase(apiBase);
144
+ const body = {
145
+ model,
146
+ max_tokens: maxTokens,
147
+ temperature,
148
+ stream: true,
149
+ messages: [{ role: 'user', content: prompt }],
150
+ };
151
+ if (systemPrompt) body.system = systemPrompt;
152
+
153
+ const ac = new AbortController();
154
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
155
+ try {
156
+ const response = await fetch(`${base}/messages`, {
157
+ method: 'POST',
158
+ headers: buildAnthropicHeaders(apiKey),
159
+ body: JSON.stringify(body),
160
+ signal: ac.signal,
161
+ });
162
+ if (response.status === 401 || response.status === 403) {
163
+ throw buildAiError('ai_provider_auth', `ai ${response.status}: authentication failed`);
164
+ }
165
+ if (response.status === 429) {
166
+ throw buildAiError('ai_rate_limited_provider', 'ai 429: rate limit exceeded');
167
+ }
168
+ if (!response.ok) {
169
+ const errBody = await response.text().catch(() => '');
170
+ throw new Error(`ai ${response.status}: ${errBody.slice(0, 220)}`);
171
+ }
172
+
173
+ const reader = response.body.getReader();
174
+ const decoder = new TextDecoder();
175
+ let fullContent = '';
176
+ let buffer = '';
177
+ while (true) {
178
+ const { done, value } = await reader.read();
179
+ if (done) break;
180
+ buffer += decoder.decode(value, { stream: true });
181
+ const lines = buffer.split('\n');
182
+ buffer = lines.pop() || '';
183
+ for (const line of lines) {
184
+ if (!line.startsWith('data: ')) continue;
185
+ const raw = line.slice(6).trim();
186
+ if (!raw || raw === '[DONE]') continue;
187
+ try {
188
+ const parsed = JSON.parse(raw);
189
+ const chunk = parsed?.delta?.text || parsed?.text || '';
190
+ if (!chunk) continue;
191
+ fullContent += chunk;
192
+ onToken(chunk, fullContent);
193
+ } catch {
194
+ // ignore malformed SSE chunk
195
+ }
196
+ }
197
+ }
198
+ return { content: fullContent.trim(), reasoning: '', model };
199
+ } finally {
200
+ clearTimeout(timer);
201
+ }
202
+ }
203
+
59
204
  // Single non-streaming call with retry logic
60
205
  // Returns { content, reasoning, model } or throws
61
206
  export async function call(prompt, {
@@ -70,6 +215,9 @@ export async function call(prompt, {
70
215
  } = {}) {
71
216
  const base = String(apiBase || '').replace(/\/$/, '');
72
217
  if (!base || !model) throw buildAiError('ai_unavailable', 'AI not configured');
218
+ if (isAnthropicBase(base)) {
219
+ return callAnthropic(prompt, { apiBase: base, apiKey, model, maxTokens, timeoutMs, systemPrompt, temperature });
220
+ }
73
221
 
74
222
  const messages = systemPrompt
75
223
  ? [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }]
@@ -155,6 +303,9 @@ export async function stream(prompt, onToken, {
155
303
  } = {}) {
156
304
  const base = String(apiBase || '').replace(/\/$/, '');
157
305
  if (!base || !model) throw buildAiError('ai_unavailable', 'AI not configured');
306
+ if (isAnthropicBase(base)) {
307
+ return streamAnthropic(prompt, onToken, { apiBase: base, apiKey, model, maxTokens, timeoutMs, systemPrompt, temperature });
308
+ }
158
309
 
159
310
  const messages = systemPrompt
160
311
  ? [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }]
package/src/api/routes.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // All API route handlers
2
2
 
3
3
  import express from 'express';
4
- import { search, searchStream, getEnabledProviders, getDocCache } from '../search/engine.js';
4
+ import { search, searchStream, getEnabledProviders, getDocCache, ALLOWED_ENGINES } from '../search/engine.js';
5
5
  import { batchFetch, fetchReadableDocument } from '../fetch/document.js';
6
6
  import { generateSummary, testConnection } from '../ai/orchestrator.js';
7
7
  import { refineQuery } from '../ai/query.js';
@@ -11,8 +11,9 @@ import { detectProfileTarget, scanProfile, PROFILER_PLATFORMS } from '../profile
11
11
  import { fetchBlueskyPosts, fetchBlueskyActors, fetchGdeltArticles } from '../social/search.js';
12
12
  import { scrapeTPB, scrape1337x, extractMagnetFromUrl } from '../torrent/scrapers.js';
13
13
 
14
- const APP_VERSION = '0.3.0';
14
+ const APP_VERSION = '0.3.2';
15
15
  const ALLOWED_CATEGORIES = new Set(['web', 'images', 'news']);
16
+ const ALLOWED_LANGS = new Set(['auto', 'it-IT', 'en-US', 'es-ES', 'fr-FR', 'de-DE', 'pt-PT', 'ru-RU', 'zh-CN', 'ja-JP']);
16
17
 
17
18
  function parseCategory(raw) {
18
19
  const category = String(raw || 'web').trim().toLowerCase();
@@ -22,13 +23,184 @@ function parseCategory(raw) {
22
23
  function parseEngines(raw) {
23
24
  if (!raw) return [];
24
25
  const source = Array.isArray(raw) ? raw.join(',') : String(raw);
25
- return [...new Set(
26
+ const parsed = [...new Set(
26
27
  source
27
28
  .split(',')
28
29
  .map((entry) => entry.trim().toLowerCase())
29
30
  .filter(Boolean)
30
31
  .slice(0, 12)
31
32
  )];
33
+ if (!parsed.every((engine) => ALLOWED_ENGINES.has(engine))) return null;
34
+ return parsed;
35
+ }
36
+
37
+ function normalizeLang(raw) {
38
+ const input = String(raw || '').trim();
39
+ if (!input) return null;
40
+ const lower = input.toLowerCase();
41
+ const exact = [...ALLOWED_LANGS].find((lang) => lang.toLowerCase() === lower);
42
+ if (exact) return exact;
43
+ const shortMap = {
44
+ it: 'it-IT',
45
+ en: 'en-US',
46
+ es: 'es-ES',
47
+ fr: 'fr-FR',
48
+ de: 'de-DE',
49
+ pt: 'pt-PT',
50
+ ru: 'ru-RU',
51
+ zh: 'zh-CN',
52
+ ja: 'ja-JP',
53
+ };
54
+ return shortMap[lower] || null;
55
+ }
56
+
57
+ function resolveLang(rawLang, acceptLanguageHeader = '') {
58
+ const normalized = normalizeLang(rawLang || 'auto');
59
+ if (normalized && normalized !== 'auto') return normalized;
60
+ const first = String(acceptLanguageHeader || '')
61
+ .split(',')
62
+ .map((part) => part.trim().split(';')[0])
63
+ .find(Boolean);
64
+ const fromHeader = normalizeLang(first || '');
65
+ return fromHeader || 'en-US';
66
+ }
67
+
68
+ function normalizeBase(rawBase) {
69
+ const raw = String(rawBase || '').trim();
70
+ if (!raw) return '';
71
+ return raw.replace(/\/$/, '');
72
+ }
73
+
74
+ function detectModelProvider(base, preset = '') {
75
+ const p = String(preset || '').trim().toLowerCase();
76
+ if (p === 'openroute') return 'openrouter';
77
+ if (p && p !== 'custom') return p;
78
+ const b = String(base || '').toLowerCase();
79
+ if (b.includes('anthropic.com')) return 'anthropic';
80
+ if (b.includes('openrouter.ai')) return 'openrouter';
81
+ if (b.includes('openai.com')) return 'openai';
82
+ if (b.includes('chutes.ai')) return 'chutes';
83
+ if (b.includes(':11434')) return 'ollama';
84
+ if (b.includes(':1234')) return 'lmstudio';
85
+ if (b.includes(':8080')) return 'llamacpp';
86
+ return 'openai_compat';
87
+ }
88
+
89
+ function parseModelPayload(payload) {
90
+ if (Array.isArray(payload)) {
91
+ return payload.map((item) => typeof item === 'string' ? item.trim() : String(item?.id || item?.name || '').trim()).filter(Boolean);
92
+ }
93
+ if (!payload || typeof payload !== 'object') return [];
94
+ if (Array.isArray(payload.data)) {
95
+ return payload.data.map((item) => String(item?.id || '').trim()).filter(Boolean);
96
+ }
97
+ if (Array.isArray(payload.models)) {
98
+ return payload.models.map((item) => typeof item === 'string' ? item.trim() : String(item?.id || item?.name || '').trim()).filter(Boolean);
99
+ }
100
+ if (Array.isArray(payload?.result?.models)) {
101
+ return payload.result.models.map((item) => typeof item === 'string' ? item.trim() : String(item?.id || item?.name || '').trim()).filter(Boolean);
102
+ }
103
+ return [];
104
+ }
105
+
106
+ function buildModelAuthVariants(provider, apiKey) {
107
+ const key = String(apiKey || '').trim();
108
+ if (!key) return [{ Accept: 'application/json' }];
109
+ if (provider === 'chutes') {
110
+ return [
111
+ { Accept: 'application/json', Authorization: `Bearer ${key}` },
112
+ { Accept: 'application/json', 'x-api-key': key },
113
+ { Accept: 'application/json', Authorization: `Bearer ${key}`, 'x-api-key': key },
114
+ ];
115
+ }
116
+ return [{ Accept: 'application/json', Authorization: `Bearer ${key}` }];
117
+ }
118
+
119
+ async function fetchOpenAiCompatibleModels(base, apiKey, provider = 'openai_compat', timeoutMs = 10000) {
120
+ const variants = buildModelAuthVariants(provider, apiKey);
121
+ for (const headers of variants) {
122
+ const ac = new AbortController();
123
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
124
+ try {
125
+ const response = await fetch(`${base}/models`, { headers, signal: ac.signal });
126
+ if (!response.ok) continue;
127
+ const payload = await response.json();
128
+ const parsed = parseModelPayload(payload);
129
+ if (parsed.length > 0) return parsed;
130
+ } catch {
131
+ // try next variant
132
+ } finally {
133
+ clearTimeout(timer);
134
+ }
135
+ }
136
+ return [];
137
+ }
138
+
139
+ async function fetchOpenRouterModels(base, apiKey, timeoutMs = 10000) {
140
+ const endpoint = base.includes('/api/v1') ? `${base}/models` : `${base}/api/v1/models`;
141
+ const ac = new AbortController();
142
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
143
+ try {
144
+ const headers = { Accept: 'application/json' };
145
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
146
+ const response = await fetch(endpoint, { headers, signal: ac.signal });
147
+ if (!response.ok) return [];
148
+ const payload = await response.json();
149
+ return parseModelPayload(payload);
150
+ } catch {
151
+ return [];
152
+ } finally {
153
+ clearTimeout(timer);
154
+ }
155
+ }
156
+
157
+ async function fetchAnthropicModels(base, apiKey, timeoutMs = 10000) {
158
+ if (!apiKey) return [];
159
+ const endpoint = base.endsWith('/v1') ? `${base}/models` : `${base}/v1/models`;
160
+ const ac = new AbortController();
161
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
162
+ try {
163
+ const response = await fetch(endpoint, {
164
+ headers: {
165
+ Accept: 'application/json',
166
+ 'x-api-key': apiKey,
167
+ 'anthropic-version': '2023-06-01',
168
+ },
169
+ signal: ac.signal,
170
+ });
171
+ if (!response.ok) return [];
172
+ const payload = await response.json();
173
+ return parseModelPayload(payload);
174
+ } catch {
175
+ return [];
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
180
+
181
+ async function fetchOllamaModels(base, timeoutMs = 10000) {
182
+ const origin = (() => {
183
+ try {
184
+ const u = new URL(base);
185
+ return `${u.protocol}//${u.host}`;
186
+ } catch {
187
+ return base;
188
+ }
189
+ })();
190
+ const ac = new AbortController();
191
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
192
+ try {
193
+ const response = await fetch(`${origin}/api/tags`, { headers: { Accept: 'application/json' }, signal: ac.signal });
194
+ if (!response.ok) return [];
195
+ const payload = await response.json();
196
+ return (payload?.models || [])
197
+ .map((item) => String(item?.name || '').trim())
198
+ .filter(Boolean);
199
+ } catch {
200
+ return [];
201
+ } finally {
202
+ clearTimeout(timer);
203
+ }
32
204
  }
33
205
 
34
206
  export function createRouter(config, rateLimiters) {
@@ -68,6 +240,7 @@ export function createRouter(config, rateLimiters) {
68
240
  '/api/magnet': { post: { summary: 'Extract magnet from page URL' } },
69
241
  '/api/scan': { post: { summary: 'Scan site pages by query' } },
70
242
  '/api/config': { get: { summary: 'Read config (masked)' }, post: { summary: 'Update config' } },
243
+ '/api/config/models': { post: { summary: 'List AI models from selected provider endpoint' } },
71
244
  },
72
245
  });
73
246
  });
@@ -84,11 +257,12 @@ export function createRouter(config, rateLimiters) {
84
257
  if (!q) return sendJson(res, 400, { error: 'missing_query', message: 'q parameter required' });
85
258
  if (q.length > cfg.search.max_query_length) return sendJson(res, 400, { error: 'query_too_long' });
86
259
 
87
- const lang = String(req.query.lang || 'en-US');
260
+ const lang = resolveLang(req.query.lang, req.headers['accept-language']);
88
261
  const safe = String(req.query.safe || '1');
89
262
  const page = Number(req.query.page || '1');
90
263
  const category = parseCategory(req.query.cat);
91
264
  const engines = parseEngines(req.query.engines);
265
+ if (engines === null) return sendJson(res, 400, { error: 'invalid_engines', message: 'engines must be a comma-separated allowlisted set.' });
92
266
 
93
267
  try {
94
268
  const result = await search({ query: q, lang, safe, page, category, engines }, cfg);
@@ -111,11 +285,12 @@ export function createRouter(config, rateLimiters) {
111
285
  if (!q) return sendJson(res, 400, { error: 'missing_query' });
112
286
  if (q.length > cfg.search.max_query_length) return sendJson(res, 400, { error: 'query_too_long' });
113
287
 
114
- const lang = String(req.query.lang || 'en-US');
288
+ const lang = resolveLang(req.query.lang, req.headers['accept-language']);
115
289
  const safe = String(req.query.safe || '1');
116
290
  const page = Number(req.query.page || '1');
117
291
  const category = parseCategory(req.query.cat);
118
292
  const engines = parseEngines(req.query.engines);
293
+ if (engines === null) return sendJson(res, 400, { error: 'invalid_engines', message: 'engines must be a comma-separated allowlisted set.' });
119
294
 
120
295
  applySecurityHeaders(res);
121
296
  res.setHeader('Content-Type', 'text/event-stream');
@@ -134,6 +309,8 @@ export function createRouter(config, rateLimiters) {
134
309
  lang,
135
310
  results: chunk.results || [],
136
311
  providers: chunk.providers || [],
312
+ degraded: chunk.degraded === true,
313
+ engineStats: chunk.engineStats || { responded: chunk.providers || [], failed: [], unstable: [], health: {} },
137
314
  });
138
315
  } else {
139
316
  send({
@@ -143,8 +320,8 @@ export function createRouter(config, rateLimiters) {
143
320
  results: chunk.results || [],
144
321
  allResults: chunk.results || [],
145
322
  providers: chunk.providers || [],
146
- degraded: false,
147
- engineStats: { responded: chunk.providers || [], failed: [], unstable: [], health: {} },
323
+ degraded: chunk.degraded === true,
324
+ engineStats: chunk.engineStats || { responded: chunk.providers || [], failed: [], unstable: [], health: {} },
148
325
  });
149
326
  }
150
327
  }
@@ -189,7 +366,7 @@ export function createRouter(config, rateLimiters) {
189
366
  if (!cfg.ai?.enabled) return sendJson(res, 200, { refined_query: req.body?.query, intent: 'other', also_search: [] });
190
367
 
191
368
  const query = String(req.body?.query || '').trim();
192
- const lang = String(req.body?.lang || 'en-US');
369
+ const lang = resolveLang(req.body?.lang, req.headers['accept-language']);
193
370
  if (!query) return sendJson(res, 400, { error: 'missing_query' });
194
371
 
195
372
  const result = await refineQuery({ query, lang }, cfg.ai);
@@ -213,7 +390,7 @@ export function createRouter(config, rateLimiters) {
213
390
  }
214
391
 
215
392
  const query = String(req.body?.query || '').trim();
216
- const lang = String(req.body?.lang || 'en-US');
393
+ const lang = resolveLang(req.body?.lang, req.headers['accept-language']);
217
394
  const results = Array.isArray(req.body?.results) ? req.body.results : [];
218
395
  const session = Array.isArray(req.body?.session) ? req.body.session.slice(-4) : [];
219
396
  const streamMode = req.body?.stream !== false;
@@ -305,6 +482,38 @@ export function createRouter(config, rateLimiters) {
305
482
  sendJson(res, 405, { error: 'method_not_allowed', message: 'Use POST /api/config/test-ai' });
306
483
  });
307
484
 
485
+ // ─── Fetch provider model list ───────────────────────────────────────────
486
+ router.post('/api/config/models', express.json({ limit: '8kb' }), async (req, res) => {
487
+ const ip = req.clientIp;
488
+ if (!rateLimiters.checkGeneral(ip)) {
489
+ return sendRateLimited(res, { windowMs: rateLimiters.windowMs });
490
+ }
491
+ const cfg = config.getConfig();
492
+ const base = normalizeBase(req.body?.api_base || cfg.ai?.api_base || '');
493
+ const apiKey = String(req.body?.api_key || cfg.ai?.api_key || '');
494
+ const preset = String(req.body?.preset || '').trim().toLowerCase();
495
+ if (!base) return sendJson(res, 400, { ok: false, error: 'missing_api_base' });
496
+
497
+ const provider = detectModelProvider(base, preset);
498
+ let models = [];
499
+ if (provider === 'anthropic') {
500
+ models = await fetchAnthropicModels(base, apiKey);
501
+ } else if (provider === 'openrouter') {
502
+ models = await fetchOpenRouterModels(base, apiKey);
503
+ } else if (provider === 'ollama') {
504
+ models = await fetchOllamaModels(base);
505
+ if (models.length === 0) {
506
+ models = await fetchOpenAiCompatibleModels(base, apiKey, provider);
507
+ }
508
+ } else {
509
+ models = await fetchOpenAiCompatibleModels(base, apiKey, provider);
510
+ }
511
+
512
+ models = [...new Set(models)].slice(0, 80);
513
+ applySecurityHeaders(res);
514
+ res.json({ ok: true, provider, models });
515
+ });
516
+
308
517
  // ─── Test search provider ─────────────────────────────────────────────────
309
518
  router.get('/api/config/test-provider/:name', async (req, res) => {
310
519
  const cfg = config.getConfig();
@@ -327,7 +536,11 @@ export function createRouter(config, rateLimiters) {
327
536
  results = await mojeekSearch({ query: testQuery, config: cfg, timeoutMs: 8000 });
328
537
  } else if (name === 'searxng') {
329
538
  const { search: searxSearch } = await import('../search/providers/searxng.js');
330
- results = await searxSearch({ query: testQuery, config: cfg, timeoutMs: 8000 });
539
+ const response = await searxSearch({ query: testQuery, config: cfg, timeoutMs: 8000 });
540
+ results = Array.isArray(response) ? response : (response?.results || []);
541
+ } else if (name === 'github') {
542
+ const { search: githubSearch } = await import('../search/providers/github.js');
543
+ results = await githubSearch({ query: testQuery, config: cfg, timeoutMs: 8000 });
331
544
  } else {
332
545
  return sendJson(res, 400, { error: 'unknown_provider' });
333
546
  }