Eric Xu commited on
Commit
2f01b87
·
unverified ·
1 Parent(s): 7454acd

Pass LLM credentials per-request, never store server-side

Browse files

API key flows from browser JS memory → query params → backend middleware
→ LLM call. Nothing is stored in server memory or disk. Each request
is self-contained.

Users can: bring their own key (any provider), or use a server-default
key set via HF Spaces Secrets.

Files changed (2) hide show
  1. web/app.py +58 -57
  2. web/static/index.html +37 -22
web/app.py CHANGED
@@ -23,7 +23,7 @@ from datetime import datetime
23
  from pathlib import Path
24
 
25
  from dotenv import load_dotenv
26
- from fastapi import FastAPI, HTTPException
27
  from fastapi.staticfiles import StaticFiles
28
  from fastapi.responses import FileResponse
29
  from pydantic import BaseModel
@@ -113,24 +113,46 @@ def get_nemotron(data_dir=None):
113
  return None
114
 
115
 
116
- # Per-user API key override (for hosted/Spaces mode)
117
- _user_llm_config: dict = {} # session-scoped, not persistent
118
 
119
-
120
- def get_client(api_key_override=None):
121
- key = api_key_override or _user_llm_config.get("api_key") or os.getenv("LLM_API_KEY")
122
- base = _user_llm_config.get("base_url") or os.getenv("LLM_BASE_URL")
123
  if not key:
124
  raise HTTPException(400, "No API key configured. Enter your key above.")
125
  return OpenAI(api_key=key, base_url=base)
126
 
127
 
128
- def get_model():
129
- return _user_llm_config.get("model") or os.getenv("LLM_MODEL_NAME", "openai/gpt-4o-mini")
130
 
131
 
132
  IS_SPACES = bool(os.getenv("SPACE_ID"))
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  NEMOTRON_DATASETS = {
135
  "USA": "nvidia/Nemotron-Personas-USA",
136
  "Japan": "nvidia/Nemotron-Personas-Japan",
@@ -179,21 +201,15 @@ async def index():
179
  return FileResponse(Path(__file__).parent / "static" / "index.html")
180
 
181
 
182
- class SetApiKeyInput(BaseModel):
183
- api_key: str
184
- base_url: str = ""
185
- model: str = ""
186
-
187
-
188
  @app.get("/api/config")
189
  async def get_config():
190
  """Return current LLM config and Nemotron status."""
191
  nem_path = find_nemotron_path()
192
- has_key = bool(_user_llm_config.get("api_key") or os.getenv("LLM_API_KEY"))
193
  return {
194
  "model": get_model(),
195
  "has_api_key": has_key,
196
- "base_url": _user_llm_config.get("base_url") or os.getenv("LLM_BASE_URL", ""),
197
  "nemotron_path": str(nem_path) if nem_path else None,
198
  "nemotron_available": nem_path is not None,
199
  "is_spaces": IS_SPACES,
@@ -201,16 +217,6 @@ async def get_config():
201
  }
202
 
203
 
204
- @app.post("/api/config/api-key")
205
- async def set_api_key(input: SetApiKeyInput):
206
- """Set LLM API key from the UI (for hosted/Spaces mode)."""
207
- _user_llm_config["api_key"] = input.api_key
208
- if input.base_url:
209
- _user_llm_config["base_url"] = input.base_url
210
- if input.model:
211
- _user_llm_config["model"] = input.model
212
- return {"ok": True, "model": get_model()}
213
-
214
 
215
  class SuggestChangesInput(BaseModel):
216
  entity_text: str
@@ -285,10 +291,9 @@ class InferSpecInput(BaseModel):
285
 
286
 
287
  @app.post("/api/infer-spec")
288
- async def infer_spec(input: InferSpecInput):
289
  """Infer goal and audience from entity text."""
290
- client = get_client()
291
- model = get_model()
292
 
293
  prompt = f"""Read this entity and infer two things:
294
  1. What is the most likely GOAL the author has? (what outcome they want)
@@ -327,10 +332,9 @@ Be specific to THIS entity, not generic."""
327
 
328
 
329
  @app.post("/api/suggest-changes")
330
- async def suggest_changes(input: SuggestChangesInput):
331
  """Generate candidate changes from evaluation concerns and goal."""
332
- client = get_client()
333
- model = get_model()
334
 
335
  concerns_text = "\n".join(f"- {c}" for c in input.concerns[:15])
336
  prompt = f"""Based on these evaluation results, suggest 3-5 specific, actionable changes.
@@ -370,10 +374,9 @@ Return JSON:
370
 
371
 
372
  @app.post("/api/suggest-segments")
373
- async def suggest_segments(input: SuggestSegmentsInput):
374
  """Use LLM to suggest audience segments based on entity and context."""
375
- client = get_client()
376
- model = get_model()
377
 
378
  prompt = f"""Given this entity and audience context, suggest 4-5 evaluator segments.
379
  Each segment should represent a distinct perspective that would evaluate this entity differently.
@@ -455,7 +458,7 @@ If nothing specific is stated, return {{}}."""
455
 
456
 
457
  @app.post("/api/cohort/generate")
458
- async def generate_cohort_endpoint(config: CohortConfig):
459
  """Generate a cohort — from Nemotron if available, else LLM-generated."""
460
  total = sum(s.get("count", 8) for s in config.segments)
461
 
@@ -467,8 +470,7 @@ async def generate_cohort_endpoint(config: CohortConfig):
467
  ss = _lazy_stratified_sampler()
468
 
469
  # Extract structured filters from audience context
470
- client = get_client()
471
- model = get_model()
472
  filters = extract_filters(client, model, config.audience_context, config.description)
473
  print(f"Nemotron filters from audience context: {filters}")
474
 
@@ -491,8 +493,7 @@ async def generate_cohort_endpoint(config: CohortConfig):
491
  source = "nemotron"
492
  else:
493
  # Fallback: LLM-generated
494
- client = get_client()
495
- model = get_model()
496
  all_personas = []
497
 
498
  with concurrent.futures.ThreadPoolExecutor(max_workers=config.parallel) as pool:
@@ -527,7 +528,8 @@ async def upload_cohort(sid: str, cohort: list[dict]):
527
  # ── SSE streaming endpoints ──────────────────────────────────────────────
528
 
529
  @app.get("/api/evaluate/stream/{sid}")
530
- async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool = False):
 
531
  """Run evaluation with Server-Sent Events for real-time progress."""
532
  if sid not in sessions:
533
  raise HTTPException(404, "Session not found")
@@ -536,15 +538,14 @@ async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool =
536
  raise HTTPException(400, "No cohort — generate or upload one first")
537
 
538
  async def event_generator():
539
- client = get_client()
540
- model = get_model()
541
  cohort = session["cohort"]
542
  entity_text = session["entity_text"]
543
  total = len(cohort)
544
  sys_prompt = SYSTEM_PROMPT + BIAS_CALIBRATION_ADDENDUM if bias_calibration else None
545
 
546
  yield {"event": "start", "data": json.dumps({
547
- "total": total, "model": model,
548
  "bias_calibration": bias_calibration,
549
  })}
550
 
@@ -553,7 +554,7 @@ async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool =
553
  t0 = time.time()
554
  with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
555
  futs = {
556
- pool.submit(evaluate_one, client, model, ev, entity_text,
557
  system_prompt=sys_prompt): i
558
  for i, ev in enumerate(cohort)
559
  }
@@ -629,7 +630,8 @@ async def prepare_counterfactual(sid: str, req: CounterfactualRequest):
629
 
630
 
631
  @app.get("/api/counterfactual/stream/{sid}")
632
- async def counterfactual_stream(sid: str, ticket: str):
 
633
  """Run counterfactual probes with SSE progress."""
634
  if sid not in sessions:
635
  raise HTTPException(404, "Session not found")
@@ -650,8 +652,7 @@ async def counterfactual_stream(sid: str, ticket: str):
650
  parallel = req.parallel
651
 
652
  async def event_generator():
653
- client = get_client()
654
- model = get_model()
655
  cohort = session["cohort"]
656
  eval_results = session["eval_results"]
657
  cohort_map = {f"{p.get('name','')}_{p.get('user_id','')}": p for p in cohort}
@@ -662,7 +663,7 @@ async def counterfactual_stream(sid: str, ticket: str):
662
  total = len(movable)
663
  has_goal = bool(goal.strip())
664
  yield {"event": "start", "data": json.dumps({
665
- "total": total, "changes": len(all_changes), "model": model,
666
  "goal": goal if has_goal else None,
667
  })}
668
 
@@ -681,7 +682,7 @@ async def counterfactual_stream(sid: str, ticket: str):
681
  "status": "computing", "message": "Scoring evaluator relevance to goal..."
682
  })}
683
  goal_weights = compute_goal_weights(
684
- client, model, eval_results, cohort_map, goal, parallel=parallel,
685
  )
686
  relevant = sum(1 for v in goal_weights.values() if v["weight"] >= 0.5)
687
  yield {"event": "goal_weights", "data": json.dumps({
@@ -697,7 +698,7 @@ async def counterfactual_stream(sid: str, ticket: str):
697
 
698
  with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
699
  futs = {
700
- pool.submit(probe_one, client, model, r, cohort_map, all_changes): i
701
  for i, r in enumerate(movable)
702
  }
703
  for fut in concurrent.futures.as_completed(futs):
@@ -742,7 +743,8 @@ async def counterfactual_stream(sid: str, ticket: str):
742
  @app.get("/api/bias-audit/stream/{sid}")
743
  async def bias_audit_stream(
744
  sid: str, probes: str = "framing,authority,order",
745
- sample: int = 10, parallel: int = 5
 
746
  ):
747
  """Run bias audit probes with SSE progress."""
748
  if sid not in sessions:
@@ -755,8 +757,7 @@ async def bias_audit_stream(
755
 
756
  async def event_generator():
757
  import random
758
- client = get_client()
759
- model = get_model()
760
  cohort = session["cohort"]
761
  entity_text = session["entity_text"]
762
 
@@ -766,7 +767,7 @@ async def bias_audit_stream(
766
  yield {"event": "start", "data": json.dumps({
767
  "probes": probe_list,
768
  "sample_size": len(evaluators),
769
- "model": model,
770
  })}
771
 
772
  all_analyses = []
@@ -811,7 +812,7 @@ async def bias_audit_stream(
811
  "analysis": analysis,
812
  })}
813
 
814
- report = generate_report(all_analyses, model)
815
  session["bias_audit"] = {"analyses": all_analyses, "report": report}
816
 
817
  yield {"event": "complete", "data": json.dumps({
 
23
  from pathlib import Path
24
 
25
  from dotenv import load_dotenv
26
+ from fastapi import FastAPI, HTTPException, Query, Request
27
  from fastapi.staticfiles import StaticFiles
28
  from fastapi.responses import FileResponse
29
  from pydantic import BaseModel
 
113
  return None
114
 
115
 
116
+ # LLM client — uses per-request headers or server env vars. Never stored.
 
117
 
118
+ def get_client(api_key=None, base_url=None):
119
+ key = api_key or os.getenv("LLM_API_KEY")
120
+ base = base_url or os.getenv("LLM_BASE_URL")
 
121
  if not key:
122
  raise HTTPException(400, "No API key configured. Enter your key above.")
123
  return OpenAI(api_key=key, base_url=base)
124
 
125
 
126
+ def get_model(model=None):
127
+ return model or os.getenv("LLM_MODEL_NAME", "openai/gpt-4o-mini")
128
 
129
 
130
  IS_SPACES = bool(os.getenv("SPACE_ID"))
131
 
132
+
133
+ def _llm_from_params(api_key: str = "", base_url: str = "", model: str = ""):
134
+ """Extract LLM config from params. Falls back to env vars. Never stored."""
135
+ return (
136
+ get_client(api_key=api_key or None, base_url=base_url or None),
137
+ get_model(model=model or None),
138
+ )
139
+
140
+
141
+ @app.middleware("http")
142
+ async def inject_llm_config(request: Request, call_next):
143
+ """Make LLM creds available from query params on any request."""
144
+ request.state.api_key = request.query_params.get("api_key", "")
145
+ request.state.base_url = request.query_params.get("base_url", "")
146
+ request.state.model = request.query_params.get("model", "")
147
+ return await call_next(request)
148
+
149
+
150
+ def llm_from_request(request: Request):
151
+ """Get LLM client+model from the current request's query params."""
152
+ return _llm_from_params(
153
+ request.state.api_key, request.state.base_url, request.state.model
154
+ )
155
+
156
  NEMOTRON_DATASETS = {
157
  "USA": "nvidia/Nemotron-Personas-USA",
158
  "Japan": "nvidia/Nemotron-Personas-Japan",
 
201
  return FileResponse(Path(__file__).parent / "static" / "index.html")
202
 
203
 
 
 
 
 
 
 
204
  @app.get("/api/config")
205
  async def get_config():
206
  """Return current LLM config and Nemotron status."""
207
  nem_path = find_nemotron_path()
208
+ has_key = bool(os.getenv("LLM_API_KEY")) # server-level key only; per-session keys checked client-side
209
  return {
210
  "model": get_model(),
211
  "has_api_key": has_key,
212
+ "base_url": os.getenv("LLM_BASE_URL", ""),
213
  "nemotron_path": str(nem_path) if nem_path else None,
214
  "nemotron_available": nem_path is not None,
215
  "is_spaces": IS_SPACES,
 
217
  }
218
 
219
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  class SuggestChangesInput(BaseModel):
222
  entity_text: str
 
291
 
292
 
293
  @app.post("/api/infer-spec")
294
+ async def infer_spec(input: InferSpecInput, request: Request):
295
  """Infer goal and audience from entity text."""
296
+ client, model = llm_from_request(request)
 
297
 
298
  prompt = f"""Read this entity and infer two things:
299
  1. What is the most likely GOAL the author has? (what outcome they want)
 
332
 
333
 
334
  @app.post("/api/suggest-changes")
335
+ async def suggest_changes(input: SuggestChangesInput, request: Request):
336
  """Generate candidate changes from evaluation concerns and goal."""
337
+ client, model = llm_from_request(request)
 
338
 
339
  concerns_text = "\n".join(f"- {c}" for c in input.concerns[:15])
340
  prompt = f"""Based on these evaluation results, suggest 3-5 specific, actionable changes.
 
374
 
375
 
376
  @app.post("/api/suggest-segments")
377
+ async def suggest_segments(input: SuggestSegmentsInput, request: Request):
378
  """Use LLM to suggest audience segments based on entity and context."""
379
+ client, model = llm_from_request(request)
 
380
 
381
  prompt = f"""Given this entity and audience context, suggest 4-5 evaluator segments.
382
  Each segment should represent a distinct perspective that would evaluate this entity differently.
 
458
 
459
 
460
  @app.post("/api/cohort/generate")
461
+ async def generate_cohort_endpoint(config: CohortConfig, request: Request):
462
  """Generate a cohort — from Nemotron if available, else LLM-generated."""
463
  total = sum(s.get("count", 8) for s in config.segments)
464
 
 
470
  ss = _lazy_stratified_sampler()
471
 
472
  # Extract structured filters from audience context
473
+ client, model = llm_from_request(request)
 
474
  filters = extract_filters(client, model, config.audience_context, config.description)
475
  print(f"Nemotron filters from audience context: {filters}")
476
 
 
493
  source = "nemotron"
494
  else:
495
  # Fallback: LLM-generated
496
+ client, model = llm_from_request(request)
 
497
  all_personas = []
498
 
499
  with concurrent.futures.ThreadPoolExecutor(max_workers=config.parallel) as pool:
 
528
  # ── SSE streaming endpoints ──────────────────────────────────────────────
529
 
530
  @app.get("/api/evaluate/stream/{sid}")
531
+ async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool = False,
532
+ api_key: str = "", base_url: str = "", model: str = ""):
533
  """Run evaluation with Server-Sent Events for real-time progress."""
534
  if sid not in sessions:
535
  raise HTTPException(404, "Session not found")
 
538
  raise HTTPException(400, "No cohort — generate or upload one first")
539
 
540
  async def event_generator():
541
+ client, mdl = _llm_from_params(api_key, base_url, model)
 
542
  cohort = session["cohort"]
543
  entity_text = session["entity_text"]
544
  total = len(cohort)
545
  sys_prompt = SYSTEM_PROMPT + BIAS_CALIBRATION_ADDENDUM if bias_calibration else None
546
 
547
  yield {"event": "start", "data": json.dumps({
548
+ "total": total, "model": mdl,
549
  "bias_calibration": bias_calibration,
550
  })}
551
 
 
554
  t0 = time.time()
555
  with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
556
  futs = {
557
+ pool.submit(evaluate_one, client, mdl, ev, entity_text,
558
  system_prompt=sys_prompt): i
559
  for i, ev in enumerate(cohort)
560
  }
 
630
 
631
 
632
  @app.get("/api/counterfactual/stream/{sid}")
633
+ async def counterfactual_stream(sid: str, ticket: str,
634
+ api_key: str = "", base_url: str = "", model: str = ""):
635
  """Run counterfactual probes with SSE progress."""
636
  if sid not in sessions:
637
  raise HTTPException(404, "Session not found")
 
652
  parallel = req.parallel
653
 
654
  async def event_generator():
655
+ client, mdl = _llm_from_params(api_key, base_url, model)
 
656
  cohort = session["cohort"]
657
  eval_results = session["eval_results"]
658
  cohort_map = {f"{p.get('name','')}_{p.get('user_id','')}": p for p in cohort}
 
663
  total = len(movable)
664
  has_goal = bool(goal.strip())
665
  yield {"event": "start", "data": json.dumps({
666
+ "total": total, "changes": len(all_changes), "model": mdl,
667
  "goal": goal if has_goal else None,
668
  })}
669
 
 
682
  "status": "computing", "message": "Scoring evaluator relevance to goal..."
683
  })}
684
  goal_weights = compute_goal_weights(
685
+ client, mdl, eval_results, cohort_map, goal, parallel=parallel,
686
  )
687
  relevant = sum(1 for v in goal_weights.values() if v["weight"] >= 0.5)
688
  yield {"event": "goal_weights", "data": json.dumps({
 
698
 
699
  with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
700
  futs = {
701
+ pool.submit(probe_one, client, mdl, r, cohort_map, all_changes): i
702
  for i, r in enumerate(movable)
703
  }
704
  for fut in concurrent.futures.as_completed(futs):
 
743
  @app.get("/api/bias-audit/stream/{sid}")
744
  async def bias_audit_stream(
745
  sid: str, probes: str = "framing,authority,order",
746
+ sample: int = 10, parallel: int = 5,
747
+ api_key: str = "", base_url: str = "", model: str = ""
748
  ):
749
  """Run bias audit probes with SSE progress."""
750
  if sid not in sessions:
 
757
 
758
  async def event_generator():
759
  import random
760
+ client, mdl = _llm_from_params(api_key, base_url, model)
 
761
  cohort = session["cohort"]
762
  entity_text = session["entity_text"]
763
 
 
767
  yield {"event": "start", "data": json.dumps({
768
  "probes": probe_list,
769
  "sample_size": len(evaluators),
770
+ "model": mdl,
771
  })}
772
 
773
  all_analyses = []
 
812
  "analysis": analysis,
813
  })}
814
 
815
+ report = generate_report(all_analyses, mdl)
816
  session["bias_audit"] = {"analyses": all_analyses, "report": report}
817
 
818
  yield {"event": "complete", "data": json.dumps({
web/static/index.html CHANGED
@@ -594,6 +594,28 @@ const TEMPLATES = {
594
  let sessionId = null;
595
  let evalResultsData = null;
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  // XSS sanitization helper
598
  function esc(str) {
599
  if (str == null) return '';
@@ -640,27 +662,17 @@ async function init() {
640
  // Changes are auto-generated from evaluation concerns
641
  }
642
 
643
- async function saveApiKey() {
644
  const key = document.getElementById('apiKeyInput').value.trim();
645
  if (!key) return alert('Enter your API key.');
646
- const baseUrl = document.getElementById('apiBaseUrl').value.trim();
647
- const model = document.getElementById('apiModel').value.trim();
648
 
649
- try {
650
- const resp = await fetch('/api/config/api-key', {
651
- method: 'POST',
652
- headers: {'Content-Type': 'application/json'},
653
- body: JSON.stringify({api_key: key, base_url: baseUrl, model: model}),
654
- });
655
- const data = await resp.json();
656
- if (!resp.ok) throw new Error(data.detail || 'Failed');
657
 
658
- document.getElementById('configBadge').textContent = data.model;
659
- document.getElementById('configBadge').className = 'config-badge ok';
660
- document.getElementById('apiKeySetup').classList.add('hidden');
661
- } catch (e) {
662
- alert('Failed to set API key: ' + e.message);
663
- }
664
  }
665
 
666
  async function setupNemotron() {
@@ -762,7 +774,7 @@ async function inferSpec() {
762
  if (goalField.value.trim() && audienceField.value.trim()) return;
763
 
764
  try {
765
- const resp = await fetch('/api/infer-spec', {
766
  method: 'POST',
767
  headers: {'Content-Type': 'application/json'},
768
  body: JSON.stringify({entity_text: text}),
@@ -820,7 +832,7 @@ async function runFullPipeline() {
820
  document.getElementById('pipelineProgressBar').style.width = '10%';
821
  logStep('Choosing the right panel segments...');
822
 
823
- const segResp = await fetch('/api/suggest-segments', {
824
  method: 'POST',
825
  headers: {'Content-Type': 'application/json'},
826
  body: JSON.stringify({
@@ -845,7 +857,7 @@ async function runFullPipeline() {
845
  logStep('Building panel members for each segment...');
846
 
847
  const desc = audienceCtx || `People evaluating: ${text.substring(0, 200)}`;
848
- const cohortResp = await fetch('/api/cohort/generate', {
849
  method: 'POST',
850
  headers: {'Content-Type': 'application/json'},
851
  body: JSON.stringify({description: desc, audience_context: audienceCtx, segments, parallel: 3}),
@@ -882,6 +894,7 @@ async function runFullPipeline() {
882
 
883
  await new Promise((resolve, reject) => {
884
  const params = new URLSearchParams({parallel: 5, bias_calibration: biasCal});
 
885
  const es = new EventSource(`/api/evaluate/stream/${sessionId}?${params}`);
886
 
887
  es.addEventListener('start', (e) => {
@@ -974,7 +987,7 @@ async function runDirections() {
974
 
975
  // Phase 2: LLM generates candidate changes from concerns
976
  document.getElementById('cfProgressText').textContent = 'Proposing changes to test...';
977
- const suggestResp = await fetch('/api/suggest-changes', {
978
  method: 'POST',
979
  headers: {'Content-Type': 'application/json'},
980
  body: JSON.stringify({entity_text: entityText, goal, concerns}),
@@ -1006,7 +1019,9 @@ async function runDirections() {
1006
  const {ticket} = await prepResp.json();
1007
 
1008
  await new Promise((resolve, reject) => {
1009
- const es = new EventSource(`/api/counterfactual/stream/${sessionId}?ticket=${ticket}`);
 
 
1010
 
1011
  es.addEventListener('start', (e) => {
1012
  const d = JSON.parse(e.data);
 
594
  let sessionId = null;
595
  let evalResultsData = null;
596
 
597
+ // LLM credentials — stored only in browser JS memory, never persisted
598
+ let llmApiKey = '';
599
+ let llmBaseUrl = '';
600
+ let llmModel = '';
601
+
602
+ function llmHeaders() {
603
+ return {'Content-Type': 'application/json', 'X-LLM-Key': llmApiKey, 'X-LLM-Base': llmBaseUrl, 'X-LLM-Model': llmModel};
604
+ }
605
+
606
+ function llmQueryParams() {
607
+ const p = new URLSearchParams();
608
+ if (llmApiKey) p.set('api_key', llmApiKey);
609
+ if (llmBaseUrl) p.set('base_url', llmBaseUrl);
610
+ if (llmModel) p.set('model', llmModel);
611
+ return p;
612
+ }
613
+
614
+ function apiUrl(path) {
615
+ const q = llmQueryParams().toString();
616
+ return q ? `${path}?${q}` : path;
617
+ }
618
+
619
  // XSS sanitization helper
620
  function esc(str) {
621
  if (str == null) return '';
 
662
  // Changes are auto-generated from evaluation concerns
663
  }
664
 
665
+ function saveApiKey() {
666
  const key = document.getElementById('apiKeyInput').value.trim();
667
  if (!key) return alert('Enter your API key.');
 
 
668
 
669
+ llmApiKey = key;
670
+ llmBaseUrl = document.getElementById('apiBaseUrl').value.trim();
671
+ llmModel = document.getElementById('apiModel').value.trim() || 'openai/gpt-4o-mini';
 
 
 
 
 
672
 
673
+ document.getElementById('configBadge').textContent = llmModel;
674
+ document.getElementById('configBadge').className = 'config-badge ok';
675
+ document.getElementById('apiKeySetup').classList.add('hidden');
 
 
 
676
  }
677
 
678
  async function setupNemotron() {
 
774
  if (goalField.value.trim() && audienceField.value.trim()) return;
775
 
776
  try {
777
+ const resp = await fetch(apiUrl('/api/infer-spec'), {
778
  method: 'POST',
779
  headers: {'Content-Type': 'application/json'},
780
  body: JSON.stringify({entity_text: text}),
 
832
  document.getElementById('pipelineProgressBar').style.width = '10%';
833
  logStep('Choosing the right panel segments...');
834
 
835
+ const segResp = await fetch(apiUrl('/api/suggest-segments'), {
836
  method: 'POST',
837
  headers: {'Content-Type': 'application/json'},
838
  body: JSON.stringify({
 
857
  logStep('Building panel members for each segment...');
858
 
859
  const desc = audienceCtx || `People evaluating: ${text.substring(0, 200)}`;
860
+ const cohortResp = await fetch(apiUrl('/api/cohort/generate'), {
861
  method: 'POST',
862
  headers: {'Content-Type': 'application/json'},
863
  body: JSON.stringify({description: desc, audience_context: audienceCtx, segments, parallel: 3}),
 
894
 
895
  await new Promise((resolve, reject) => {
896
  const params = new URLSearchParams({parallel: 5, bias_calibration: biasCal});
897
+ llmQueryParams().forEach((v, k) => params.set(k, v));
898
  const es = new EventSource(`/api/evaluate/stream/${sessionId}?${params}`);
899
 
900
  es.addEventListener('start', (e) => {
 
987
 
988
  // Phase 2: LLM generates candidate changes from concerns
989
  document.getElementById('cfProgressText').textContent = 'Proposing changes to test...';
990
+ const suggestResp = await fetch(apiUrl('/api/suggest-changes'), {
991
  method: 'POST',
992
  headers: {'Content-Type': 'application/json'},
993
  body: JSON.stringify({entity_text: entityText, goal, concerns}),
 
1019
  const {ticket} = await prepResp.json();
1020
 
1021
  await new Promise((resolve, reject) => {
1022
+ const cfParams = new URLSearchParams({ticket});
1023
+ llmQueryParams().forEach((v, k) => cfParams.set(k, v));
1024
+ const es = new EventSource(`/api/counterfactual/stream/${sessionId}?${cfParams}`);
1025
 
1026
  es.addEventListener('start', (e) => {
1027
  const d = JSON.parse(e.data);