dhuser commited on
Commit
67cadad
·
1 Parent(s): 04bd46e

Scores and multi-provider

Browse files
Files changed (5) hide show
  1. app.py +57 -16
  2. provider.py +101 -47
  3. static/app.js +87 -15
  4. static/index.html +90 -33
  5. static/styles.css +14 -0
app.py CHANGED
@@ -31,7 +31,10 @@ from io_utils import (
31
  export_tsv, export_conllu, export_jsonl_finetune,
32
  )
33
  from moe import aggregate
34
- from provider import OpenRouterClient, CURATED_MODELS, test_connection_sync
 
 
 
35
  from tutorial import EXERCISES, prefill
36
 
37
 
@@ -59,7 +62,8 @@ SESSION: dict[str, Any] = {
59
  "language": "",
60
  "system_prompt": DEFAULT_SYSTEM_PROMPT,
61
  "user_template": DEFAULT_FEW_SHOT,
62
- "models": ["openai/gpt-oss-20b:free"],
 
63
  "priority": [],
64
  "temperature": 0.0,
65
  "n_icl": 5,
@@ -116,7 +120,10 @@ def _public_state() -> dict:
116
  },
117
  "sentences": sess["sentences"],
118
  "presets": [{"key": k, "label": label} for k, label in list_presets()],
119
- "curated_models": CURATED_MODELS,
 
 
 
120
  "aggregators": AGGREGATORS,
121
  "exercises": [
122
  {"idx": i, "title": ex.title, "summary": ex.summary, "language": ex.language_code, "models": ex.models}
@@ -149,6 +156,7 @@ class LoadExerciseReq(BaseModel):
149
 
150
 
151
  class SettingsReq(BaseModel):
 
152
  models: Optional[list[str]] = None
153
  priority: Optional[list[str]] = None
154
  temperature: Optional[float] = None
@@ -168,7 +176,8 @@ class AnnotateReq(BaseModel):
168
 
169
  class TestKeyReq(BaseModel):
170
  api_key: str
171
- model: Optional[str] = "openai/gpt-4o-mini"
 
172
 
173
 
174
  # ---------------------------------------------------------------------------
@@ -223,8 +232,20 @@ def set_task_schema(req: TaskSchemaReq):
223
 
224
  @app.post("/api/settings")
225
  def set_settings(req: SettingsReq):
 
 
 
 
 
 
 
 
226
  if req.models is not None:
227
- SESSION["models"] = list(req.models)
 
 
 
 
228
  if req.priority is not None:
229
  SESSION["priority"] = list(req.priority)
230
  if req.temperature is not None:
@@ -242,7 +263,8 @@ def set_settings(req: SettingsReq):
242
 
243
  @app.post("/api/settings/test_key")
244
  def test_key(req: TestKeyReq):
245
- ok, msg = test_connection_sync(req.api_key, model=req.model or "openai/gpt-4o-mini")
 
246
  return {"ok": ok, "message": msg}
247
 
248
 
@@ -308,7 +330,8 @@ def reset_all():
308
  """Wipe everything except the API key (which lives client-side)."""
309
  SESSION["schema"] = _default_schema().to_dict()
310
  SESSION["language"] = ""
311
- SESSION["models"] = ["openai/gpt-oss-20b:free"]
 
312
  SESSION["priority"] = []
313
  SESSION["temperature"] = 0.0
314
  SESSION["n_icl"] = 5
@@ -435,7 +458,7 @@ def icl_download():
435
 
436
  # --- annotation ------------------------------------------------------------
437
 
438
- async def _annotate_sentence(sent: dict, client: OpenRouterClient,
439
  schema: AnnotationSchema, sys_prompt: str,
440
  user_template: str, language: str,
441
  pool: ICLPool, n_icl: int, temperature: float,
@@ -495,15 +518,25 @@ async def _annotate_sentence(sent: dict, client: OpenRouterClient,
495
 
496
 
497
  @app.post("/api/annotate")
498
- async def annotate(req: AnnotateReq, x_openrouter_key: Optional[str] = Header(default=None)):
 
 
 
 
 
499
  sess = SESSION
500
- api_key = _resolve_key(x_openrouter_key)
 
 
 
501
  if not api_key:
502
- raise HTTPException(400, "Set your OpenRouter API key first.")
503
  if not sess["models"]:
504
  raise HTTPException(400, "Select at least one model.")
 
 
505
  schema_obj = schema_from_dict(sess["schema"])
506
- client = OpenRouterClient(api_key=api_key)
507
  pool: ICLPool = sess["icl_pool"]
508
  sents = sess["sentences"]
509
  target_idxs = req.sentence_idxs if req.sentence_idxs is not None else list(range(len(sents)))
@@ -521,12 +554,20 @@ async def annotate(req: AnnotateReq, x_openrouter_key: Optional[str] = Header(de
521
 
522
 
523
  @app.post("/api/annotate/token")
524
- async def annotate_one_token(payload: dict, x_openrouter_key: Optional[str] = Header(default=None)):
 
 
 
 
 
525
  """Re-ask a specific model for a specific token. payload = {sent: int, tok: int, model: str}"""
526
  sess = SESSION
527
- api_key = _resolve_key(x_openrouter_key)
 
 
 
528
  if not api_key:
529
- raise HTTPException(400, "Set your OpenRouter API key first.")
530
  idx = int(payload["sent"])
531
  tidx = int(payload["tok"])
532
  model = str(payload["model"])
@@ -540,7 +581,7 @@ async def annotate_one_token(payload: dict, x_openrouter_key: Optional[str] = He
540
  language=sess["language"] or sent["language"], sentence_id=sent["id"],
541
  few_shot_examples=examples,
542
  ) + f"\n\nFocus especially on token index {tidx} (surface={tokens[tidx]!r}). Return JSON for all tokens; preserve the order."
543
- client = OpenRouterClient(api_key=api_key)
544
  result = await client.annotate_one(
545
  system=sess["system_prompt"], user=rendered_user,
546
  schema=schema, model=model, temperature=float(sess["temperature"]),
 
31
  export_tsv, export_conllu, export_jsonl_finetune,
32
  )
33
  from moe import aggregate
34
+ from provider import (
35
+ LLMClient, PROVIDERS, BASE_URLS,
36
+ CURATED_MODELS_BY_PROVIDER, test_connection_sync,
37
+ )
38
  from tutorial import EXERCISES, prefill
39
 
40
 
 
62
  "language": "",
63
  "system_prompt": DEFAULT_SYSTEM_PROMPT,
64
  "user_template": DEFAULT_FEW_SHOT,
65
+ "provider": "openrouter",
66
+ "models": list(CURATED_MODELS_BY_PROVIDER["openrouter"][:1]),
67
  "priority": [],
68
  "temperature": 0.0,
69
  "n_icl": 5,
 
120
  },
121
  "sentences": sess["sentences"],
122
  "presets": [{"key": k, "label": label} for k, label in list_presets()],
123
+ "provider": sess["provider"],
124
+ "providers": list(PROVIDERS),
125
+ "curated_models": CURATED_MODELS_BY_PROVIDER.get(sess["provider"], []),
126
+ "curated_models_by_provider": CURATED_MODELS_BY_PROVIDER,
127
  "aggregators": AGGREGATORS,
128
  "exercises": [
129
  {"idx": i, "title": ex.title, "summary": ex.summary, "language": ex.language_code, "models": ex.models}
 
156
 
157
 
158
  class SettingsReq(BaseModel):
159
+ provider: Optional[str] = None
160
  models: Optional[list[str]] = None
161
  priority: Optional[list[str]] = None
162
  temperature: Optional[float] = None
 
176
 
177
  class TestKeyReq(BaseModel):
178
  api_key: str
179
+ provider: Optional[str] = "openrouter"
180
+ model: Optional[str] = None
181
 
182
 
183
  # ---------------------------------------------------------------------------
 
232
 
233
  @app.post("/api/settings")
234
  def set_settings(req: SettingsReq):
235
+ if req.provider is not None:
236
+ if req.provider not in PROVIDERS:
237
+ raise HTTPException(400, f"Unknown provider {req.provider!r}; expected one of {list(PROVIDERS)}")
238
+ if req.provider != SESSION["provider"]:
239
+ SESSION["provider"] = req.provider
240
+ # reset to the new provider's first curated model (if any), to avoid orphan slugs
241
+ curated = CURATED_MODELS_BY_PROVIDER.get(req.provider) or []
242
+ SESSION["models"] = list(curated[:1])
243
  if req.models is not None:
244
+ models = list(req.models)
245
+ # Non-OpenRouter providers don't support MoE → keep a single model
246
+ if SESSION["provider"] != "openrouter" and len(models) > 1:
247
+ models = models[:1]
248
+ SESSION["models"] = models
249
  if req.priority is not None:
250
  SESSION["priority"] = list(req.priority)
251
  if req.temperature is not None:
 
263
 
264
  @app.post("/api/settings/test_key")
265
  def test_key(req: TestKeyReq):
266
+ provider = req.provider or "openrouter"
267
+ ok, msg = test_connection_sync(req.api_key, provider=provider, model=req.model)
268
  return {"ok": ok, "message": msg}
269
 
270
 
 
330
  """Wipe everything except the API key (which lives client-side)."""
331
  SESSION["schema"] = _default_schema().to_dict()
332
  SESSION["language"] = ""
333
+ SESSION["provider"] = "openrouter"
334
+ SESSION["models"] = list(CURATED_MODELS_BY_PROVIDER["openrouter"][:1])
335
  SESSION["priority"] = []
336
  SESSION["temperature"] = 0.0
337
  SESSION["n_icl"] = 5
 
458
 
459
  # --- annotation ------------------------------------------------------------
460
 
461
+ async def _annotate_sentence(sent: dict, client: LLMClient,
462
  schema: AnnotationSchema, sys_prompt: str,
463
  user_template: str, language: str,
464
  pool: ICLPool, n_icl: int, temperature: float,
 
518
 
519
 
520
  @app.post("/api/annotate")
521
+ async def annotate(
522
+ req: AnnotateReq,
523
+ x_api_key: Optional[str] = Header(default=None),
524
+ x_openrouter_key: Optional[str] = Header(default=None), # back-compat
525
+ x_llm_provider: Optional[str] = Header(default=None),
526
+ ):
527
  sess = SESSION
528
+ provider = (x_llm_provider or sess["provider"]).strip()
529
+ if provider not in PROVIDERS:
530
+ raise HTTPException(400, f"Unknown provider {provider!r}")
531
+ api_key = _resolve_key(x_api_key or x_openrouter_key)
532
  if not api_key:
533
+ raise HTTPException(400, f"Set your {provider} API key first.")
534
  if not sess["models"]:
535
  raise HTTPException(400, "Select at least one model.")
536
+ if provider != "openrouter" and len(sess["models"]) > 1:
537
+ raise HTTPException(400, f"MoE (multiple models) is only supported on OpenRouter. Pick one model for {provider}.")
538
  schema_obj = schema_from_dict(sess["schema"])
539
+ client = LLMClient(provider=provider, api_key=api_key)
540
  pool: ICLPool = sess["icl_pool"]
541
  sents = sess["sentences"]
542
  target_idxs = req.sentence_idxs if req.sentence_idxs is not None else list(range(len(sents)))
 
554
 
555
 
556
  @app.post("/api/annotate/token")
557
+ async def annotate_one_token(
558
+ payload: dict,
559
+ x_api_key: Optional[str] = Header(default=None),
560
+ x_openrouter_key: Optional[str] = Header(default=None),
561
+ x_llm_provider: Optional[str] = Header(default=None),
562
+ ):
563
  """Re-ask a specific model for a specific token. payload = {sent: int, tok: int, model: str}"""
564
  sess = SESSION
565
+ provider = (x_llm_provider or sess["provider"]).strip()
566
+ if provider not in PROVIDERS:
567
+ raise HTTPException(400, f"Unknown provider {provider!r}")
568
+ api_key = _resolve_key(x_api_key or x_openrouter_key)
569
  if not api_key:
570
+ raise HTTPException(400, f"Set your {provider} API key first.")
571
  idx = int(payload["sent"])
572
  tidx = int(payload["tok"])
573
  model = str(payload["model"])
 
581
  language=sess["language"] or sent["language"], sentence_id=sent["id"],
582
  few_shot_examples=examples,
583
  ) + f"\n\nFocus especially on token index {tidx} (surface={tokens[tidx]!r}). Return JSON for all tokens; preserve the order."
584
+ client = LLMClient(provider=provider, api_key=api_key)
585
  result = await client.annotate_one(
586
  system=sess["system_prompt"], user=rendered_user,
587
  schema=schema, model=model, temperature=float(sess["temperature"]),
provider.py CHANGED
@@ -1,8 +1,13 @@
1
- """OpenRouter async client for structured-output annotation.
2
 
3
- One gateway, many models. Mirrors the OpenAI chat-completions interface so
4
- JSON-Schema response_format works uniformly across Claude, GPT, Mistral,
5
- Llama, Qwen, etc.
 
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
@@ -10,26 +15,50 @@ import asyncio
10
  import json
11
  import time
12
  from dataclasses import dataclass
13
- from typing import Any, Optional
14
 
15
  import httpx
16
 
17
  from schemas import AnnotationSchema, to_json_schema, validate as schema_validate
18
  from prompts import VALIDATION_RETRY
19
 
20
- OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
21
  DEFAULT_TIMEOUT = 60.0
22
 
23
- CURATED_MODELS = [
24
- "openai/gpt-oss-20b:free",
25
- "google/gemma-4-26b-a4b-it:free",
26
- "meta-llama/llama-3.3-70b-instruct:free",
27
- "qwen/qwen3-next-80b-a3b-instruct:free",
28
- "deepseek/deepseek-v4-flash:free",
29
- "mistralai/mistral-nemo",
30
- "mistralai/mistral-small-24b-instruct-2501",
31
- "mistralai/ministral-3b-2512",
32
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  @dataclass
@@ -42,15 +71,26 @@ class ModelResult:
42
  raw: str = ""
43
 
44
 
45
- class OpenRouterClient:
46
- def __init__(self, api_key: str, http_referer: str = "https://lrec2026-llm-annotator.local", app_name: str = "LREC2026 LLM-as-Annotator"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  self.api_key = api_key
48
- self.headers = {
49
- "Authorization": f"Bearer {api_key}",
50
- "HTTP-Referer": http_referer,
51
- "X-Title": app_name,
52
- "Content-Type": "application/json",
53
- }
54
 
55
  async def annotate_one(
56
  self,
@@ -71,7 +111,6 @@ class OpenRouterClient:
71
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
72
  ann, err = self._parse_and_validate(raw_text, schema)
73
  if err:
74
- # one retry with validator error appended
75
  retry_msg = VALIDATION_RETRY + f"\n\nValidator errors:\n{err}\n\nPrevious response:\n{raw_text}"
76
  msgs.append({"role": "assistant", "content": raw_text})
77
  msgs.append({"role": "user", "content": retry_msg})
@@ -102,28 +141,32 @@ class OpenRouterClient:
102
  return await asyncio.gather(*coros)
103
 
104
  async def _call(self, client: httpx.AsyncClient, msgs: list[dict], json_schema: dict, model: str, temperature: float) -> str:
105
- payload = {
106
- "model": model,
107
- "messages": msgs,
108
- "temperature": temperature,
109
- "response_format": {
110
- "type": "json_schema",
111
- "json_schema": {"name": "annotation", "strict": True, "schema": json_schema},
112
- },
113
- }
114
- resp = await client.post(OPENROUTER_URL, headers=self.headers, json=payload)
 
 
 
 
 
 
115
  if resp.status_code >= 400:
116
- # Some models don't honor strict json_schema; fall back to json_object then trust prompt instructions.
117
  payload["response_format"] = {"type": "json_object"}
118
- resp = await client.post(OPENROUTER_URL, headers=self.headers, json=payload)
119
  resp.raise_for_status()
120
  data = resp.json()
121
  return data["choices"][0]["message"]["content"] or ""
122
 
123
  @staticmethod
124
  def _parse_and_validate(raw_text: str, schema: AnnotationSchema) -> tuple[Optional[dict], str]:
125
- text = raw_text.strip()
126
- # tolerate ```json ... ``` fences
127
  if text.startswith("```"):
128
  text = text.strip("`")
129
  if text.lower().startswith("json"):
@@ -139,15 +182,20 @@ class OpenRouterClient:
139
  return ann, ""
140
 
141
 
142
- def test_connection_sync(api_key: str, model: str = "openai/gpt-oss-20b:free") -> tuple[bool, str]:
143
- """Quick blocking test for the Models tab "Test connection" button."""
 
 
 
 
 
 
 
 
144
  try:
145
  resp = httpx.post(
146
- OPENROUTER_URL,
147
- headers={
148
- "Authorization": f"Bearer {api_key}",
149
- "Content-Type": "application/json",
150
- },
151
  json={
152
  "model": model,
153
  "messages": [{"role": "user", "content": "Reply with the single word: OK"}],
@@ -159,6 +207,12 @@ def test_connection_sync(api_key: str, model: str = "openai/gpt-oss-20b:free") -
159
  if resp.status_code >= 400:
160
  return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
161
  content = resp.json()["choices"][0]["message"]["content"]
162
- return True, f"Connected. Reply: {content!r}"
163
  except Exception as e:
164
  return False, str(e)
 
 
 
 
 
 
 
1
+ """LLM provider client for structured-output annotation.
2
 
3
+ Supports three OpenAI-compatible providers via parametric base URL:
4
+ - openrouter : https://openrouter.ai/api/v1 (MoE: many models behind one key)
5
+ - mistral : https://api.mistral.ai/v1
6
+ - openai : https://api.openai.com/v1
7
+
8
+ All three accept the same OpenAI Chat Completions request shape, including
9
+ `response_format` (json_schema strict on OpenAI; json_object on Mistral; varies
10
+ on OpenRouter — we auto-fall back if the strict mode is rejected).
11
  """
12
  from __future__ import annotations
13
 
 
15
  import json
16
  import time
17
  from dataclasses import dataclass
18
+ from typing import Optional
19
 
20
  import httpx
21
 
22
  from schemas import AnnotationSchema, to_json_schema, validate as schema_validate
23
  from prompts import VALIDATION_RETRY
24
 
 
25
  DEFAULT_TIMEOUT = 60.0
26
 
27
+ PROVIDERS = ("openrouter", "mistral", "openai")
28
+
29
+ BASE_URLS = {
30
+ "openrouter": "https://openrouter.ai/api/v1",
31
+ "mistral": "https://api.mistral.ai/v1",
32
+ "openai": "https://api.openai.com/v1",
33
+ }
34
+
35
+ CURATED_MODELS_BY_PROVIDER: dict[str, list[str]] = {
36
+ "openrouter": [
37
+ "openai/gpt-oss-20b:free",
38
+ "google/gemma-4-26b-a4b-it:free",
39
+ "meta-llama/llama-3.3-70b-instruct:free",
40
+ "qwen/qwen3-next-80b-a3b-instruct:free",
41
+ "deepseek/deepseek-v4-flash:free",
42
+ "mistralai/mistral-nemo",
43
+ "mistralai/mistral-small-24b-instruct-2501",
44
+ "mistralai/ministral-3b-2512",
45
+ ],
46
+ "mistral": [
47
+ "mistral-small-2603",
48
+ "mistral-large-2512",
49
+ "ministral-8b-2512",
50
+ "ministral-3b-2512",
51
+ ],
52
+ "openai": [
53
+ "gpt-5-mini-2025-08-07",
54
+ "gpt-5-nano-2025-08-07",
55
+ "gpt-5-2025-08-07",
56
+ "gpt-4o-mini-2024-07-18",
57
+ ],
58
+ }
59
+
60
+ # Back-compat alias used by other modules
61
+ CURATED_MODELS = CURATED_MODELS_BY_PROVIDER["openrouter"]
62
 
63
 
64
  @dataclass
 
71
  raw: str = ""
72
 
73
 
74
+ def _build_headers(provider: str, api_key: str) -> dict:
75
+ h = {
76
+ "Authorization": f"Bearer {api_key}",
77
+ "Content-Type": "application/json",
78
+ }
79
+ if provider == "openrouter":
80
+ h["HTTP-Referer"] = "https://lrec2026-llm-annotator.local"
81
+ h["X-Title"] = "LREC2026 LLM-as-Annotator"
82
+ return h
83
+
84
+
85
+ class LLMClient:
86
+ def __init__(self, provider: str, api_key: str):
87
+ if provider not in BASE_URLS:
88
+ raise ValueError(f"Unknown provider {provider!r}; expected one of {PROVIDERS}")
89
+ self.provider = provider
90
  self.api_key = api_key
91
+ self.base_url = BASE_URLS[provider]
92
+ self.endpoint = self.base_url + "/chat/completions"
93
+ self.headers = _build_headers(provider, api_key)
 
 
 
94
 
95
  async def annotate_one(
96
  self,
 
111
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
112
  ann, err = self._parse_and_validate(raw_text, schema)
113
  if err:
 
114
  retry_msg = VALIDATION_RETRY + f"\n\nValidator errors:\n{err}\n\nPrevious response:\n{raw_text}"
115
  msgs.append({"role": "assistant", "content": raw_text})
116
  msgs.append({"role": "user", "content": retry_msg})
 
141
  return await asyncio.gather(*coros)
142
 
143
  async def _call(self, client: httpx.AsyncClient, msgs: list[dict], json_schema: dict, model: str, temperature: float) -> str:
144
+ # Strict json_schema works on OpenAI and most OpenRouter models. For Mistral and
145
+ # for some open-source models routed via OpenRouter, fall back to json_object.
146
+ if self.provider == "mistral":
147
+ payload = {
148
+ "model": model, "messages": msgs, "temperature": temperature,
149
+ "response_format": {"type": "json_object"},
150
+ }
151
+ else:
152
+ payload = {
153
+ "model": model, "messages": msgs, "temperature": temperature,
154
+ "response_format": {
155
+ "type": "json_schema",
156
+ "json_schema": {"name": "annotation", "strict": True, "schema": json_schema},
157
+ },
158
+ }
159
+ resp = await client.post(self.endpoint, headers=self.headers, json=payload)
160
  if resp.status_code >= 400:
 
161
  payload["response_format"] = {"type": "json_object"}
162
+ resp = await client.post(self.endpoint, headers=self.headers, json=payload)
163
  resp.raise_for_status()
164
  data = resp.json()
165
  return data["choices"][0]["message"]["content"] or ""
166
 
167
  @staticmethod
168
  def _parse_and_validate(raw_text: str, schema: AnnotationSchema) -> tuple[Optional[dict], str]:
169
+ text = (raw_text or "").strip()
 
170
  if text.startswith("```"):
171
  text = text.strip("`")
172
  if text.lower().startswith("json"):
 
182
  return ann, ""
183
 
184
 
185
+ def test_connection_sync(api_key: str, provider: str = "openrouter", model: Optional[str] = None) -> tuple[bool, str]:
186
+ """Quick blocking test for the 'Test' button in the key modal."""
187
+ if provider not in BASE_URLS:
188
+ return False, f"Unknown provider {provider!r}"
189
+ if not model:
190
+ models = CURATED_MODELS_BY_PROVIDER.get(provider) or []
191
+ model = models[0] if models else None
192
+ if not model:
193
+ return False, "No model configured for this provider."
194
+ url = BASE_URLS[provider] + "/chat/completions"
195
  try:
196
  resp = httpx.post(
197
+ url,
198
+ headers=_build_headers(provider, api_key),
 
 
 
199
  json={
200
  "model": model,
201
  "messages": [{"role": "user", "content": "Reply with the single word: OK"}],
 
207
  if resp.status_code >= 400:
208
  return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
209
  content = resp.json()["choices"][0]["message"]["content"]
210
+ return True, f"Connected ({provider} / {model}). Reply: {content!r}"
211
  except Exception as e:
212
  return False, str(e)
213
+
214
+
215
+ # Back-compat shim — old callers used OpenRouterClient(api_key=...).
216
+ class OpenRouterClient(LLMClient):
217
+ def __init__(self, api_key: str, **kwargs):
218
+ super().__init__(provider="openrouter", api_key=api_key)
static/app.js CHANGED
@@ -16,7 +16,8 @@ function annotator() {
16
  guideDismissed: false,
17
  moeBannerDismissed: false,
18
  moeHintDismissed: false,
19
- localKey: '', // client-side OpenRouter key; never sent to /api/state, never persisted on server
 
20
 
21
  state: {
22
  schema: null,
@@ -26,6 +27,9 @@ function annotator() {
26
  system_prompt: '',
27
  user_template: '',
28
  has_env_key: false,
 
 
 
29
  models: [],
30
  priority: [],
31
  temperature: 0,
@@ -80,15 +84,25 @@ function annotator() {
80
  get totalDisagreements() {
81
  return this.state.sentences.reduce((a, s) => a + (s.n_disagreements || 0), 0);
82
  },
 
 
 
 
 
 
 
83
  get hasKey() {
84
- return !!this.localKey || !!this.state.has_env_key;
 
 
85
  },
86
  get canRun() {
87
  return this.hasKey && this.state.models.length > 0 && this.state.sentences.length > 0;
88
  },
89
  keyHeaders() {
90
- // Send the client-side key as a header. Server never stores it.
91
- return this.localKey ? { 'X-OpenRouter-Key': this.localKey } : {};
 
92
  },
93
 
94
  // ----------- init -----------
@@ -96,7 +110,17 @@ function annotator() {
96
  this.guideDismissed = localStorage.getItem('guideDismissed') === '1';
97
  this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1';
98
  this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1';
99
- this.localKey = sessionStorage.getItem('openrouter_key') || '';
 
 
 
 
 
 
 
 
 
 
100
  await this.refresh();
101
  try {
102
  const r = await fetch('/api/cheatsheet');
@@ -297,6 +321,51 @@ function annotator() {
297
  return parts[parts.length - 1];
298
  },
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  modelTokenSummary(ann, tidx) {
301
  const t = (ann.tokens || [])[tidx] || {};
302
  const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence');
@@ -335,6 +404,12 @@ function annotator() {
335
  this.applyState(await r.json());
336
  },
337
 
 
 
 
 
 
 
338
  saveKey() {
339
  const k = (this.keyEditor.value || '').trim();
340
  if (!k) {
@@ -345,22 +420,20 @@ function annotator() {
345
  }
346
  return;
347
  }
348
- this.localKey = k;
349
- try { sessionStorage.setItem('openrouter_key', k); } catch (e) {}
350
  this.keyEditor.value = '';
351
  this.keyEditor.result = '';
352
  this.keyEditor.ok = false;
353
- this.toast(`✓ Key saved in this tab (${k.length} chars). Pill above should turn green.`, 'ok');
354
  this.closeModal();
355
  },
356
 
357
  clearKey() {
358
- this.localKey = '';
359
- try { sessionStorage.removeItem('openrouter_key'); } catch (e) {}
360
  this.keyEditor.value = '';
361
  this.keyEditor.result = '';
362
  this.keyEditor.ok = false;
363
- this.toast('Key cleared from this tab.', 'ok');
364
  },
365
 
366
  async testKey(autoSaveOnSuccess = false) {
@@ -369,15 +442,14 @@ function annotator() {
369
  this.keyEditor.testing = true;
370
  this.keyEditor.result = '';
371
  try {
372
- const r = await fetch('/api/settings/test_key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: k }) });
373
  const j = await r.json();
374
  this.keyEditor.ok = j.ok;
375
  this.keyEditor.result = (j.ok ? '✓ ' : '✗ ') + j.message;
376
  if (j.ok && autoSaveOnSuccess) {
377
- this.localKey = k;
378
- try { sessionStorage.setItem('openrouter_key', k); } catch (e) {}
379
  this.keyEditor.value = '';
380
- this.toast(`✓ Key tested & saved (${k.length} chars).`, 'ok');
381
  }
382
  } catch (e) {
383
  this.keyEditor.ok = false;
 
16
  guideDismissed: false,
17
  moeBannerDismissed: false,
18
  moeHintDismissed: false,
19
+ // Per-provider client-side keys; persisted in sessionStorage only
20
+ localKeys: { openrouter: '', mistral: '', openai: '' },
21
 
22
  state: {
23
  schema: null,
 
27
  system_prompt: '',
28
  user_template: '',
29
  has_env_key: false,
30
+ provider: 'openrouter',
31
+ providers: ['openrouter', 'mistral', 'openai'],
32
+ curated_models_by_provider: {},
33
  models: [],
34
  priority: [],
35
  temperature: 0,
 
84
  get totalDisagreements() {
85
  return this.state.sentences.reduce((a, s) => a + (s.n_disagreements || 0), 0);
86
  },
87
+ // ----------- key helpers (per-provider) -----------
88
+ get localKey() { return this.localKeys[this.state.provider] || ''; },
89
+ setLocalKey(value) {
90
+ const p = this.state.provider;
91
+ this.localKeys = { ...this.localKeys, [p]: value };
92
+ try { sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys)); } catch (e) {}
93
+ },
94
  get hasKey() {
95
+ // env key is OpenRouter-only on the server side
96
+ const envOk = this.state.provider === 'openrouter' && !!this.state.has_env_key;
97
+ return !!this.localKey || envOk;
98
  },
99
  get canRun() {
100
  return this.hasKey && this.state.models.length > 0 && this.state.sentences.length > 0;
101
  },
102
  keyHeaders() {
103
+ const h = { 'X-LLM-Provider': this.state.provider };
104
+ if (this.localKey) h['X-API-Key'] = this.localKey;
105
+ return h;
106
  },
107
 
108
  // ----------- init -----------
 
110
  this.guideDismissed = localStorage.getItem('guideDismissed') === '1';
111
  this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1';
112
  this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1';
113
+ // Load per-provider keys; migrate legacy single-key key if present
114
+ try {
115
+ const raw = sessionStorage.getItem('llm_keys');
116
+ if (raw) this.localKeys = { openrouter: '', mistral: '', openai: '', ...JSON.parse(raw) };
117
+ const legacy = sessionStorage.getItem('openrouter_key');
118
+ if (legacy && !this.localKeys.openrouter) {
119
+ this.localKeys = { ...this.localKeys, openrouter: legacy };
120
+ sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys));
121
+ sessionStorage.removeItem('openrouter_key');
122
+ }
123
+ } catch (e) {}
124
  await this.refresh();
125
  try {
126
  const r = await fetch('/api/cheatsheet');
 
321
  return parts[parts.length - 1];
322
  },
323
 
324
+ // Per-model accuracy on a single sentence, computed vs the current consensus
325
+ // (i.e., what the user sees after MoE aggregation + their corrections).
326
+ // Skips confidence/comment (same `min`/`priority` aggregators ignored for disagreements).
327
+ modelAccuracy(sent) {
328
+ if (!sent || sent.status !== 'done') return [];
329
+ const perModel = sent.per_model || {};
330
+ const modelNames = Object.keys(perModel);
331
+ if (modelNames.length === 0) return [];
332
+ const quiet = new Set(['min', 'priority']);
333
+ const fields = (this.state.schema?.fields || []).filter(f => !quiet.has(f.aggregator));
334
+ const out = [];
335
+ for (const m of modelNames) {
336
+ const tokens = perModel[m].tokens || [];
337
+ let total = 0, correct = 0;
338
+ const n = Math.min(tokens.length, sent.tokens.length);
339
+ for (let i = 0; i < n; i++) {
340
+ const got = tokens[i] || {};
341
+ const ref = sent.tokens[i] || {};
342
+ for (const f of fields) {
343
+ if (f.type === 'object') {
344
+ for (const sub of (f.subfields || [])) {
345
+ const a = (got[f.name] || {})[sub.name] ?? null;
346
+ const b = (ref[f.name] || {})[sub.name] ?? null;
347
+ total++;
348
+ if (a === b) correct++;
349
+ }
350
+ } else {
351
+ const a = got[f.name] ?? null;
352
+ const b = ref[f.name] ?? null;
353
+ total++;
354
+ if (a === b) correct++;
355
+ }
356
+ }
357
+ }
358
+ out.push({ model: m, pct: total > 0 ? Math.round(100 * correct / total) : 0, correct, total });
359
+ }
360
+ return out.sort((a, b) => b.pct - a.pct);
361
+ },
362
+
363
+ accuracyClass(pct) {
364
+ if (pct >= 90) return 'accuracy-pill-high';
365
+ if (pct >= 70) return 'accuracy-pill-mid';
366
+ return 'accuracy-pill-low';
367
+ },
368
+
369
  modelTokenSummary(ann, tidx) {
370
  const t = (ann.tokens || [])[tidx] || {};
371
  const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence');
 
404
  this.applyState(await r.json());
405
  },
406
 
407
+ async setProvider(p) {
408
+ if (!this.state.providers.includes(p)) return;
409
+ await this.saveSettings({ provider: p });
410
+ this.toast(`Provider: ${p}. Models reset to its defaults.`, 'ok');
411
+ },
412
+
413
  saveKey() {
414
  const k = (this.keyEditor.value || '').trim();
415
  if (!k) {
 
420
  }
421
  return;
422
  }
423
+ this.setLocalKey(k);
 
424
  this.keyEditor.value = '';
425
  this.keyEditor.result = '';
426
  this.keyEditor.ok = false;
427
+ this.toast(`✓ ${this.state.provider} key saved in this tab (${k.length} chars). Pill above should turn green.`, 'ok');
428
  this.closeModal();
429
  },
430
 
431
  clearKey() {
432
+ this.setLocalKey('');
 
433
  this.keyEditor.value = '';
434
  this.keyEditor.result = '';
435
  this.keyEditor.ok = false;
436
+ this.toast(`${this.state.provider} key cleared from this tab.`, 'ok');
437
  },
438
 
439
  async testKey(autoSaveOnSuccess = false) {
 
442
  this.keyEditor.testing = true;
443
  this.keyEditor.result = '';
444
  try {
445
+ const r = await fetch('/api/settings/test_key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: k, provider: this.state.provider }) });
446
  const j = await r.json();
447
  this.keyEditor.ok = j.ok;
448
  this.keyEditor.result = (j.ok ? '✓ ' : '✗ ') + j.message;
449
  if (j.ok && autoSaveOnSuccess) {
450
+ this.setLocalKey(k);
 
451
  this.keyEditor.value = '';
452
+ this.toast(`✓ ${this.state.provider} key tested & saved (${k.length} chars).`, 'ok');
453
  }
454
  } catch (e) {
455
  this.keyEditor.ok = false;
static/index.html CHANGED
@@ -24,7 +24,7 @@
24
  }
25
  }
26
  </script>
27
- <link rel="stylesheet" href="/static/styles.css?v=20260516b">
28
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.10/dist/cdn.min.js"></script>
29
  </head>
30
  <body class="font-sans bg-ink-50 text-ink-900 min-h-screen" x-data="annotator()" x-init="init()" x-cloak>
@@ -53,19 +53,19 @@
53
 
54
  <!-- Models pill -->
55
  <button @click="modal='models'" class="pill"
56
- :class="state.models.length >= 2 ? 'pill-moe' : ''"
57
  :title="state.models.join(', ')">
58
- <span class="pill-icon" x-text="state.models.length >= 2 ? '🧠' : '🤖'"></span>
59
- <span class="pill-label" x-text="state.models.length >= 2 ? 'MoE' : 'Model'"></span>
60
- <span class="pill-value" x-text="state.models.length + (state.models.length >= 2 ? ' active' : '')"></span>
61
  </button>
62
 
63
  <!-- API key pill -->
64
  <button @click="modal='key'" class="pill" :class="hasKey ? 'pill-ok' : 'pill-warn'"
65
- :title="localKey ? 'Key stored in this tab only (sessionStorage). Click to manage.' : (state.has_env_key ? 'Using server-side OPENROUTER_API_KEY env.' : 'No key yet.')">
66
  <span class="pill-icon" x-text="hasKey ? '🔑' : '⚠'"></span>
67
- <span class="pill-label">OpenRouter</span>
68
- <span class="pill-value" x-text="localKey ? 'tab-only' : (state.has_env_key ? 'shared env' : 'add key')"></span>
69
  </button>
70
 
71
  <div class="flex-1"></div>
@@ -179,8 +179,8 @@
179
  </div>
180
  </section>
181
 
182
- <!-- MoE explainer — when ≥2 models are selected -->
183
- <section x-show="state.models.length >= 2 && !moeBannerDismissed && state.sentences.length > 0"
184
  class="rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-2.5 text-xs text-amber-900 flex items-center gap-3" x-transition>
185
  <span class="text-base">🧠</span>
186
  <div class="flex-1">
@@ -189,7 +189,7 @@
189
  </div>
190
  <button @click="moeBannerDismissed=true" class="btn btn-ghost btn-icon">✕</button>
191
  </section>
192
- <section x-show="state.models.length === 1 && state.sentences.length > 0 && !moeHintDismissed"
193
  class="rounded-xl border border-ink-200 bg-white px-4 py-2.5 text-xs text-ink-700 flex items-center gap-3" x-transition>
194
  <span class="text-base">💡</span>
195
  <div class="flex-1">
@@ -247,6 +247,19 @@
247
  </button>
248
  </header>
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  <div class="p-4">
251
  <div class="tokens-flow">
252
  <template x-for="(tok, tidx) in sent.tokens" :key="tidx + '-' + rev">
@@ -441,25 +454,52 @@
441
  <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
442
  </header>
443
  <div class="p-5 space-y-4">
444
- <p class="text-xs text-ink-500">Pick one model for single inference, or 2+ to enable <strong>Mixture-of-Experts</strong> (parallel calls + majority vote, with disagreements highlighted on each token).</p>
445
- <div class="space-y-1.5 max-h-72 overflow-y-auto">
446
- <template x-for="m in allDisplayableModels()" :key="m">
447
- <label class="flex items-center gap-3 p-2 rounded hover:bg-ink-50 cursor-pointer">
448
- <input type="checkbox" :checked="state.models.includes(m)" @change="toggleModel(m)">
449
- <span class="font-mono text-sm" x-text="m"></span>
450
- <span class="badge badge-uploaded text-[10px]" x-show="!state.curated_models.includes(m)">custom</span>
451
- </label>
452
- </template>
 
 
 
 
 
 
 
 
 
453
  </div>
 
 
454
  <div>
455
- <label class="lbl">Custom OpenRouter slug</label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  <div class="flex gap-2 mt-1">
457
- <input class="input flex-1" type="text" x-model="modelEditor.custom" placeholder="provider/model-id">
458
  <button class="btn btn-secondary" @click="addCustomModel()">Add</button>
459
  </div>
460
  </div>
461
- <div>
462
- <label class="lbl">MoE priority (tie-break order)</label>
 
 
463
  <input class="input" type="text" x-model="modelEditor.priority"
464
  @change="saveSettings({priority: modelEditor.priority.split(',').map(s => s.trim()).filter(Boolean)})"
465
  placeholder="model1, model2, model3">
@@ -472,24 +512,36 @@
472
  <div class="modal-backdrop" x-show="modal === 'key'" @click.self="closeModal()" x-transition>
473
  <div class="modal-card max-w-md">
474
  <header class="modal-header">
475
- <h2 class="font-semibold">OpenRouter API key</h2>
476
  <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
477
  </header>
478
  <div class="p-5 space-y-3">
 
 
 
 
 
 
 
 
 
 
 
 
479
  <div class="rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-xs text-emerald-900">
480
- <strong>🔒 Tab-scoped storage.</strong> Your key lives only in this browser tab's <code>sessionStorage</code>. It is sent as an <code>X-OpenRouter-Key</code> header on each annotation request and <strong>never stored on the server</strong>. Closing the tab wipes it. Use <strong>Clear key</strong> below at any time.
481
  </div>
482
 
483
  <template x-if="localKey">
484
  <div class="rounded-lg bg-ink-50 border border-ink-200 p-3 text-xs flex items-center gap-2">
485
  <span class="text-emerald-700">✓</span>
486
- <span class="text-ink-700 flex-1">A key is set in this tab.</span>
487
- <button class="btn btn-ghost text-xs text-red-600" @click="clearKey()">Clear key</button>
488
  </div>
489
  </template>
490
 
491
  <label class="lbl" x-text="localKey ? 'Replace with a new key' : 'Paste your key, then press Enter (or click Save)'"></label>
492
- <input class="input" type="password" placeholder="sk-or-v1-…"
493
  x-model="keyEditor.value"
494
  @keydown.enter.prevent="saveKey()"
495
  x-ref="keyInput"
@@ -500,20 +552,25 @@
500
  <div class="flex items-center gap-2">
501
  <button class="btn btn-primary flex-1" @click="saveKey()" x-text="localKey ? 'Replace key' : 'Save key (this tab only)'"></button>
502
  <button class="btn btn-secondary" @click="testKey(true)" :disabled="keyEditor.testing"
503
- title="Test against OpenRouter and save if it works">
504
  <span x-show="!keyEditor.testing">Test &amp; save</span>
505
  <span x-show="keyEditor.testing" class="spinner-sm"></span>
506
  </button>
507
  </div>
508
  <div class="text-xs" x-show="keyEditor.result" :class="keyEditor.ok ? 'text-emerald-700' : 'text-red-600'" x-text="keyEditor.result"></div>
509
 
510
- <template x-if="state.has_env_key">
511
  <div class="text-[11px] text-ink-500 border-t border-ink-100 pt-2">
512
  ⓘ A server-side <code>OPENROUTER_API_KEY</code> is also configured. If you don't set a tab-key, the server fallback is used.
513
  </div>
514
  </template>
515
 
516
- <p class="text-[11px] text-ink-400">Get a key → <a class="text-accent-600 underline" target="_blank" href="https://openrouter.ai/keys">openrouter.ai/keys</a></p>
 
 
 
 
 
517
  </div>
518
  </div>
519
  </div>
@@ -831,6 +888,6 @@
831
  </template>
832
  </div>
833
 
834
- <script src="/static/app.js?v=20260516b"></script>
835
  </body>
836
  </html>
 
24
  }
25
  }
26
  </script>
27
+ <link rel="stylesheet" href="/static/styles.css?v=20260516c">
28
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.10/dist/cdn.min.js"></script>
29
  </head>
30
  <body class="font-sans bg-ink-50 text-ink-900 min-h-screen" x-data="annotator()" x-init="init()" x-cloak>
 
53
 
54
  <!-- Models pill -->
55
  <button @click="modal='models'" class="pill"
56
+ :class="(state.provider === 'openrouter' && state.models.length >= 2) ? 'pill-moe' : ''"
57
  :title="state.models.join(', ')">
58
+ <span class="pill-icon" x-text="(state.provider === 'openrouter' && state.models.length >= 2) ? '🧠' : '🤖'"></span>
59
+ <span class="pill-label" x-text="(state.provider === 'openrouter' && state.models.length >= 2) ? 'MoE' : 'Model'"></span>
60
+ <span class="pill-value" x-text="state.models.length + ((state.provider === 'openrouter' && state.models.length >= 2) ? ' active' : '')"></span>
61
  </button>
62
 
63
  <!-- API key pill -->
64
  <button @click="modal='key'" class="pill" :class="hasKey ? 'pill-ok' : 'pill-warn'"
65
+ :title="localKey ? state.provider + ' key stored in this tab only. Click to manage.' : (state.provider === 'openrouter' && state.has_env_key ? 'Using server-side OPENROUTER_API_KEY env.' : 'No key yet for ' + state.provider)">
66
  <span class="pill-icon" x-text="hasKey ? '🔑' : '⚠'"></span>
67
+ <span class="pill-label capitalize" x-text="state.provider"></span>
68
+ <span class="pill-value" x-text="localKey ? 'tab-only' : ((state.provider === 'openrouter' && state.has_env_key) ? 'shared env' : 'add key')"></span>
69
  </button>
70
 
71
  <div class="flex-1"></div>
 
179
  </div>
180
  </section>
181
 
182
+ <!-- MoE explainer — only meaningful on OpenRouter -->
183
+ <section x-show="state.provider === 'openrouter' && state.models.length >= 2 && !moeBannerDismissed && state.sentences.length > 0"
184
  class="rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-2.5 text-xs text-amber-900 flex items-center gap-3" x-transition>
185
  <span class="text-base">🧠</span>
186
  <div class="flex-1">
 
189
  </div>
190
  <button @click="moeBannerDismissed=true" class="btn btn-ghost btn-icon">✕</button>
191
  </section>
192
+ <section x-show="state.provider === 'openrouter' && state.models.length === 1 && state.sentences.length > 0 && !moeHintDismissed"
193
  class="rounded-xl border border-ink-200 bg-white px-4 py-2.5 text-xs text-ink-700 flex items-center gap-3" x-transition>
194
  <span class="text-base">💡</span>
195
  <div class="flex-1">
 
247
  </button>
248
  </header>
249
 
250
+ <!-- Per-model accuracy strip (vs current consensus / your corrections) -->
251
+ <div class="px-4 pt-2 -mb-1 flex flex-wrap items-center gap-1.5"
252
+ x-show="sent.status === 'done' && Object.keys(sent.per_model || {}).length > 0">
253
+ <span class="text-[10px] uppercase tracking-wider text-ink-500 font-semibold mr-1" title="Per-model agreement with the current consensus (updates as you correct)">vs consensus</span>
254
+ <template x-for="m in modelAccuracy(sent)" :key="m.model">
255
+ <span class="accuracy-pill" :class="accuracyClass(m.pct)"
256
+ :title="`${m.model}: ${m.correct}/${m.total} task-meaningful fields match`">
257
+ <span class="font-mono opacity-80" x-text="modelShort(m.model)"></span>
258
+ <strong x-text="m.pct + '%'"></strong>
259
+ </span>
260
+ </template>
261
+ </div>
262
+
263
  <div class="p-4">
264
  <div class="tokens-flow">
265
  <template x-for="(tok, tidx) in sent.tokens" :key="tidx + '-' + rev">
 
454
  <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
455
  </header>
456
  <div class="p-5 space-y-4">
457
+ <!-- Provider selector -->
458
+ <div>
459
+ <label class="lbl">Provider</label>
460
+ <div class="grid grid-cols-3 gap-2">
461
+ <template x-for="p in state.providers" :key="p">
462
+ <button class="preset-card text-xs capitalize"
463
+ :class="state.provider === p ? 'preset-card-active' : ''"
464
+ @click="setProvider(p)">
465
+ <div class="font-semibold" x-text="p"></div>
466
+ <div class="text-[10px] text-ink-500 mt-0.5 font-mono normal-case truncate"
467
+ x-text="p === 'openrouter' ? 'openrouter.ai/api/v1 · MoE' : (p === 'mistral' ? 'api.mistral.ai/v1' : 'api.openai.com/v1')"></div>
468
+ </button>
469
+ </template>
470
+ </div>
471
+ <p class="text-[11px] text-ink-500 mt-1.5">
472
+ <span x-show="state.provider === 'openrouter'">🧠 OpenRouter supports MoE — you can pick several models.</span>
473
+ <span x-show="state.provider !== 'openrouter'">Single-model only. For Mixture-of-Experts (parallel models + vote), use <strong>OpenRouter</strong>.</span>
474
+ </p>
475
  </div>
476
+
477
+ <!-- Curated models for the active provider -->
478
  <div>
479
+ <label class="lbl">Models for <span x-text="state.provider"></span></label>
480
+ <div class="space-y-1.5 max-h-60 overflow-y-auto border border-ink-200 rounded-lg p-1">
481
+ <template x-for="m in allDisplayableModels()" :key="m">
482
+ <label class="flex items-center gap-3 p-2 rounded hover:bg-ink-50 cursor-pointer">
483
+ <input type="checkbox" :checked="state.models.includes(m)" @change="toggleModel(m)">
484
+ <span class="font-mono text-sm" x-text="m"></span>
485
+ <span class="badge badge-uploaded text-[10px]" x-show="!state.curated_models.includes(m)">custom</span>
486
+ </label>
487
+ </template>
488
+ </div>
489
+ </div>
490
+
491
+ <!-- Custom slug -->
492
+ <div>
493
+ <label class="lbl">Add a custom model slug for <span x-text="state.provider"></span></label>
494
  <div class="flex gap-2 mt-1">
495
+ <input class="input flex-1" type="text" x-model="modelEditor.custom" placeholder="model-id (e.g. mistral-medium-2505)">
496
  <button class="btn btn-secondary" @click="addCustomModel()">Add</button>
497
  </div>
498
  </div>
499
+
500
+ <!-- MoE priority only meaningful when OpenRouter + ≥2 models -->
501
+ <div x-show="state.provider === 'openrouter'">
502
+ <label class="lbl">MoE priority (tie-break order, comma-separated slugs)</label>
503
  <input class="input" type="text" x-model="modelEditor.priority"
504
  @change="saveSettings({priority: modelEditor.priority.split(',').map(s => s.trim()).filter(Boolean)})"
505
  placeholder="model1, model2, model3">
 
512
  <div class="modal-backdrop" x-show="modal === 'key'" @click.self="closeModal()" x-transition>
513
  <div class="modal-card max-w-md">
514
  <header class="modal-header">
515
+ <h2 class="font-semibold capitalize"><span x-text="state.provider"></span> API key</h2>
516
  <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
517
  </header>
518
  <div class="p-5 space-y-3">
519
+ <!-- Provider switcher (compact) -->
520
+ <div>
521
+ <label class="lbl">Provider</label>
522
+ <div class="flex gap-1.5">
523
+ <template x-for="p in state.providers" :key="p">
524
+ <button class="chip capitalize"
525
+ :class="state.provider === p ? 'chip-active' : ''"
526
+ @click="setProvider(p)" x-text="p"></button>
527
+ </template>
528
+ </div>
529
+ </div>
530
+
531
  <div class="rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-xs text-emerald-900">
532
+ <strong>🔒 Tab-scoped storage.</strong> Keys live only in this browser tab's <code>sessionStorage</code> (one slot per provider). Sent as <code>X-API-Key</code> + <code>X-LLM-Provider</code> headers never stored on the server.
533
  </div>
534
 
535
  <template x-if="localKey">
536
  <div class="rounded-lg bg-ink-50 border border-ink-200 p-3 text-xs flex items-center gap-2">
537
  <span class="text-emerald-700">✓</span>
538
+ <span class="text-ink-700 flex-1">A <span x-text="state.provider"></span> key is set in this tab.</span>
539
+ <button class="btn btn-ghost text-xs text-red-600" @click="clearKey()">Clear</button>
540
  </div>
541
  </template>
542
 
543
  <label class="lbl" x-text="localKey ? 'Replace with a new key' : 'Paste your key, then press Enter (or click Save)'"></label>
544
+ <input class="input" type="password" :placeholder="state.provider === 'openrouter' ? 'sk-or-v1-…' : (state.provider === 'openai' ? 'sk-…' : 'Mistral key')"
545
  x-model="keyEditor.value"
546
  @keydown.enter.prevent="saveKey()"
547
  x-ref="keyInput"
 
552
  <div class="flex items-center gap-2">
553
  <button class="btn btn-primary flex-1" @click="saveKey()" x-text="localKey ? 'Replace key' : 'Save key (this tab only)'"></button>
554
  <button class="btn btn-secondary" @click="testKey(true)" :disabled="keyEditor.testing"
555
+ :title="`Test against ${state.provider} and save if it works`">
556
  <span x-show="!keyEditor.testing">Test &amp; save</span>
557
  <span x-show="keyEditor.testing" class="spinner-sm"></span>
558
  </button>
559
  </div>
560
  <div class="text-xs" x-show="keyEditor.result" :class="keyEditor.ok ? 'text-emerald-700' : 'text-red-600'" x-text="keyEditor.result"></div>
561
 
562
+ <template x-if="state.has_env_key && state.provider === 'openrouter'">
563
  <div class="text-[11px] text-ink-500 border-t border-ink-100 pt-2">
564
  ⓘ A server-side <code>OPENROUTER_API_KEY</code> is also configured. If you don't set a tab-key, the server fallback is used.
565
  </div>
566
  </template>
567
 
568
+ <p class="text-[11px] text-ink-400">
569
+ Get a key →
570
+ <a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'openrouter'" href="https://openrouter.ai/keys">openrouter.ai/keys</a>
571
+ <a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'mistral'" href="https://console.mistral.ai/api-keys">console.mistral.ai/api-keys</a>
572
+ <a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'openai'" href="https://platform.openai.com/api-keys">platform.openai.com/api-keys</a>
573
+ </p>
574
  </div>
575
  </div>
576
  </div>
 
888
  </template>
889
  </div>
890
 
891
+ <script src="/static/app.js?v=20260516c"></script>
892
  </body>
893
  </html>
static/styles.css CHANGED
@@ -292,6 +292,20 @@ textarea.input { font-family: 'JetBrains Mono', monospace; }
292
  .badge-corrected { background: #d1fae5; color: #065f46; }
293
  .badge-uploaded { background: #fef3c7; color: #92400e; }
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  /* ============================================================
296
  Tables (in modals)
297
  ============================================================ */
 
292
  .badge-corrected { background: #d1fae5; color: #065f46; }
293
  .badge-uploaded { background: #fef3c7; color: #92400e; }
294
 
295
+ /* ============================================================
296
+ Per-model accuracy pills (under each sentence)
297
+ ============================================================ */
298
+ .accuracy-pill {
299
+ display: inline-flex; align-items: center; gap: 0.35em;
300
+ padding: 0.1em 0.55em;
301
+ border-radius: 999px;
302
+ font-size: 11px;
303
+ line-height: 1.4;
304
+ }
305
+ .accuracy-pill-high { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
306
+ .accuracy-pill-mid { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
307
+ .accuracy-pill-low { background: #fee2e2; color: #b91c1c; border: 1px solid #fca5a5; }
308
+
309
  /* ============================================================
310
  Tables (in modals)
311
  ============================================================ */