Spaces:
Paused
Phase 7: close the 'submit source unchanged' exploit Kimi-K2.5 found
Browse filesDuring eval, Kimi-K2.5 discovered that submitting the unmodified source
file beats the diff threshold on case_40_hindu_center_titles (0.998) and
case_26_match_slide_colors_to_theme (0.971) — paragraph alignment wasn't
in the per-shape style extractor, and theme-color references diluted
across 30 shapes for only ~3% drop on the color-swap task.
Two fixes:
1. Extend _shape_style with para_alignment + fill_theme (9 attrs total,
reweighted). Improves per-shape discrimination but doesn't fix the
averaging dilution.
2. Byte-equality anti-exploit in grade_task: if the agent's output is
byte-identical to source AND task isn't `infeasible`, score = 0.001.
This is the actual fix — it kills the whole class of "submit
unchanged" exploits regardless of which attribute the diff misses.
Validated: both exploits drop from 0.998/0.971 to 0.001; all 8 pptx eval
tasks still grade gold-vs-gold at 0.999 (no regression).
Also: inference.py now picks API key by api-base substring match
(NEBIUS_API_KEY for nebius.com, HF_TOKEN for hf, etc.) so switching
providers doesn't require env-var aliasing.
Documented in edits.md Phase 7. Flagged trajectory-collection follow-up:
SFT corpus filter must drop n_steps==1 submit_file trajectories.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- edits.md +97 -1
- graders/__init__.py +80 -26
- inference.py +13 -2
|
@@ -678,7 +678,102 @@ action — costs ~$0.50-2 in API tokens for a 22-task eval depending on model.
|
|
| 678 |
|
| 679 |
---
|
| 680 |
|
| 681 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
|
| 683 |
### Repo layout
|
| 684 |
|
|
@@ -740,6 +835,7 @@ openenv_financial_task_env/
|
|
| 740 |
| Glob the gold via `data/` | ✅ Gold moved out of `data/` for the episode |
|
| 741 |
| Read manifest.jsonl to find gold path | ⚠️ Still reachable; would need full sandbox isolation (TODO) |
|
| 742 |
| Generic-distance gaming | ✅ `eval_check` rewards spec-aligned progress |
|
|
|
|
| 743 |
| `lib_engagement` regex gaming | 🟡 Trivial cap (0.010); AST-based check would harden (TODO) |
|
| 744 |
| `mutation` spam | 🟡 Capped per-step but could spam-save garbage; could couple to progress (TODO) |
|
| 745 |
|
|
|
|
| 678 |
|
| 679 |
---
|
| 680 |
|
| 681 |
+
## Phase 7 — Live-discovered exploit + anti-exploit fix
|
| 682 |
+
|
| 683 |
+
**Trigger:** during Kimi-K2.5 eval (Apr 25, 2026), the model submitted the
|
| 684 |
+
**unmodified source file in step 1** for two tasks and scored very high:
|
| 685 |
+
|
| 686 |
+
| Task | Edit type | Score on src-unchanged submit | Why it worked |
|
| 687 |
+
|---|---|---|---|
|
| 688 |
+
| `pptarena_case_40_hindu_center_titles` | Title alignment | 0.998 | Paragraph-level `alignment` wasn't in `_shape_style`; everything else (text, position, size, font attrs) was identical between source and gold |
|
| 689 |
+
| `pptarena_case_26_match_slide_colors_to_theme` | Theme color | 0.971 | Gold uses theme-color references (None RGB); source uses explicit RGB. The mismatch dilutes across 30 shapes for only ~3% drop |
|
| 690 |
+
|
| 691 |
+
This is genuine reward hacking by an inference-time agent, exactly what the
|
| 692 |
+
"hard to game" criterion in the judging guide warns about. Two fixes
|
| 693 |
+
delivered:
|
| 694 |
+
|
| 695 |
+
### Fix 1: extended `_shape_style` (catches the per-attribute gaps)
|
| 696 |
+
|
| 697 |
+
Added two new attributes to the per-shape style extractor:
|
| 698 |
+
|
| 699 |
+
| Attribute | Source | Catches |
|
| 700 |
+
|---|---|---|
|
| 701 |
+
| `para_alignment` | `shape.text_frame.paragraphs[0].alignment` | "Center the title" / "right-align" tasks |
|
| 702 |
+
| `fill_theme` | `shape.fill.fore_color.theme_color` (when fill is solid but `.rgb` raises) | "Match colors to theme" tasks where gold uses theme refs and source uses explicit RGB |
|
| 703 |
+
|
| 704 |
+
Reweighted `_STYLE_WEIGHTS` from 7 attrs → 9 attrs:
|
| 705 |
+
|
| 706 |
+
```
|
| 707 |
+
fill_rgb 0.22 | fill_theme 0.08 | font_rgb 0.17 | para_alignment 0.15
|
| 708 |
+
font_size_pt 0.12 | line_rgb 0.08 | font_name 0.08
|
| 709 |
+
font_bold 0.05 | font_italic 0.05
|
| 710 |
+
```
|
| 711 |
+
|
| 712 |
+
Status: improves shape-level discrimination, but the **dilution problem
|
| 713 |
+
still wins** when only 2 of 55 shapes change (case_40 src-vs-gold went
|
| 714 |
+
from 0.998 → 0.997 — basically unchanged because of averaging). This is
|
| 715 |
+
why we need Fix 2.
|
| 716 |
+
|
| 717 |
+
### Fix 2: byte-equality anti-exploit at grade time (the actual fix)
|
| 718 |
+
|
| 719 |
+
Added in [`graders/__init__.py`](graders/__init__.py)'s `grade_task`:
|
| 720 |
+
**if the agent's submitted file is byte-identical to the source AND the
|
| 721 |
+
task isn't OSWorld's `infeasible` sentinel, return 0.001 immediately.**
|
| 722 |
+
|
| 723 |
+
```python
|
| 724 |
+
if src_file_exists and not is_infeasible_task:
|
| 725 |
+
if same_bytes(output_path, source_file):
|
| 726 |
+
return 0.001 # agent didn't actually do anything
|
| 727 |
+
```
|
| 728 |
+
|
| 729 |
+
This kills the entire class of "submit source unchanged" exploits across
|
| 730 |
+
all three families, regardless of which specific attribute the diff
|
| 731 |
+
misses. Validation:
|
| 732 |
+
|
| 733 |
+
| Test | Before fix | After fix |
|
| 734 |
+
|---|---|---|
|
| 735 |
+
| Submit unmodified source on `case_40` | 0.998 | **0.001** ✓ |
|
| 736 |
+
| Submit unmodified source on `case_26` | 0.971 | **0.001** ✓ |
|
| 737 |
+
| Submit gold on `case_40` | 0.999 | 0.999 ✓ no regression |
|
| 738 |
+
| Submit gold on `case_26` | 0.999 | 0.999 ✓ no regression |
|
| 739 |
+
| All 8 pptx eval tasks, gold-vs-gold | 0.999 | 0.999 ✓ no regression |
|
| 740 |
+
|
| 741 |
+
The OSWorld `infeasible` task (where not modifying *is* the correct
|
| 742 |
+
answer) is correctly excluded — that path uses the existing `infeasible`
|
| 743 |
+
evaluator function which already does its own equality check and credits
|
| 744 |
+
the agent.
|
| 745 |
+
|
| 746 |
+
### Important implication for SFT corpus building
|
| 747 |
+
|
| 748 |
+
When we eventually filter trajectories for the SFT corpus, **drop any
|
| 749 |
+
trajectory where `n_steps == 1` and the only action was `submit_file`**
|
| 750 |
+
even after this fix. Reasons:
|
| 751 |
+
1. Defense in depth — if a future grader gap appears, we don't want the
|
| 752 |
+
student model trained on "submit unchanged" wins
|
| 753 |
+
2. A real solve takes at least one code step; 1-step `submit_file` is
|
| 754 |
+
structurally suspicious
|
| 755 |
+
|
| 756 |
+
This filter is documented as a TODO for the SFT collection script.
|
| 757 |
+
|
| 758 |
+
### Re-eval needed
|
| 759 |
+
|
| 760 |
+
The Kimi-K2.5 baseline numbers from `runs/baseline_kimi_k25_eval/` were
|
| 761 |
+
collected with the pre-fix grader. The two exploited tasks are now
|
| 762 |
+
correctly graded at 0.001 instead of 0.998/0.971, lowering the run's
|
| 763 |
+
average. Either re-run Kimi on those two tasks with `--resume`, or
|
| 764 |
+
recompute the average locally:
|
| 765 |
+
|
| 766 |
+
```bash
|
| 767 |
+
# Quick local recompute (no re-inference) — assumes you already pushed
|
| 768 |
+
# updated graders. The OLD numbers are inflated; the NEW numbers reflect
|
| 769 |
+
# what Kimi actually solved.
|
| 770 |
+
```
|
| 771 |
+
|
| 772 |
+
(Recommendation: re-run with `--resume --task-ids pptarena_case_40_hindu_center_titles,pptarena_case_26_match_slide_colors_to_theme`. Costs <$0.10.)
|
| 773 |
+
|
| 774 |
+
---
|
| 775 |
+
|
| 776 |
+
## Current state (post-Phase 7)
|
| 777 |
|
| 778 |
### Repo layout
|
| 779 |
|
|
|
|
| 835 |
| Glob the gold via `data/` | ✅ Gold moved out of `data/` for the episode |
|
| 836 |
| Read manifest.jsonl to find gold path | ⚠️ Still reachable; would need full sandbox isolation (TODO) |
|
| 837 |
| Generic-distance gaming | ✅ `eval_check` rewards spec-aligned progress |
|
| 838 |
+
| **Submit-source-unchanged** (Phase 7) | ✅ Byte-equality check at grade time → 0.001 |
|
| 839 |
| `lib_engagement` regex gaming | 🟡 Trivial cap (0.010); AST-based check would harden (TODO) |
|
| 840 |
| `mutation` spam | 🟡 Capped per-step but could spam-save garbage; could couple to progress (TODO) |
|
| 841 |
|
|
@@ -253,40 +253,61 @@ def _shape_style(shape) -> Dict[str, Any]:
|
|
| 253 |
type, None color, exception) becomes None. Two None values on the same
|
| 254 |
key match; one None vs one non-None counts as a mismatch."""
|
| 255 |
style: Dict[str, Any] = {
|
| 256 |
-
"fill_rgb":
|
| 257 |
-
"
|
| 258 |
-
"
|
| 259 |
-
"
|
| 260 |
-
"
|
| 261 |
-
"
|
| 262 |
-
"
|
|
|
|
|
|
|
| 263 |
}
|
| 264 |
# ---- shape fill / line colors ----
|
|
|
|
|
|
|
|
|
|
| 265 |
try:
|
| 266 |
from pptx.enum.dml import MSO_FILL_TYPE
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
except Exception:
|
| 270 |
pass
|
| 271 |
try:
|
| 272 |
style["line_rgb"] = str(shape.line.color.rgb)
|
| 273 |
except Exception:
|
| 274 |
pass
|
| 275 |
-
# ---- first-run
|
| 276 |
try:
|
| 277 |
if shape.has_text_frame:
|
| 278 |
tf = shape.text_frame
|
| 279 |
-
if tf.paragraphs
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
style["font_size_pt"] = float(run.font.size.pt)
|
| 284 |
-
style["font_bold"] = run.font.bold
|
| 285 |
-
style["font_italic"] = run.font.italic
|
| 286 |
try:
|
| 287 |
-
style["
|
| 288 |
except Exception:
|
| 289 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
except Exception:
|
| 291 |
pass
|
| 292 |
return style
|
|
@@ -343,13 +364,15 @@ def _coord_match(r_val, o_val, denom: int) -> float:
|
|
| 343 |
|
| 344 |
|
| 345 |
_STYLE_WEIGHTS = {
|
| 346 |
-
"fill_rgb":
|
| 347 |
-
"
|
| 348 |
-
"
|
| 349 |
-
"
|
| 350 |
-
"
|
| 351 |
-
"
|
| 352 |
-
"
|
|
|
|
|
|
|
| 353 |
}
|
| 354 |
|
| 355 |
|
|
@@ -441,13 +464,26 @@ def grade_pptx(task: Dict[str, Any], output_path: str) -> float:
|
|
| 441 |
return round(0.2 * slide_score + 0.8 * avg_shape, 4)
|
| 442 |
|
| 443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
def grade_task(task: Dict[str, Any], answer: str = "", output_path: str = "") -> float:
|
| 445 |
"""Grade a task. Returns score in (0.001, 0.999).
|
| 446 |
|
| 447 |
For QA tasks: uses *answer* (text) vs task["reference_answer"].
|
| 448 |
For MODIFY tasks (xlsx): cell-diff against task["reference_file"].
|
| 449 |
For MODIFY tasks (docx): validity + diff + OSWorld evaluator.
|
| 450 |
-
For MODIFY tasks (pptx): validity + slide-count + per-shape
|
| 451 |
"""
|
| 452 |
task_type = task.get("task_type", "QA")
|
| 453 |
family = task.get("family", "xlsx")
|
|
@@ -458,6 +494,24 @@ def grade_task(task: Dict[str, Any], answer: str = "", output_path: str = "") ->
|
|
| 458 |
elif task_type == "MODIFY":
|
| 459 |
if not output_path or not Path(output_path).exists():
|
| 460 |
return 0.001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
if family == "docx":
|
| 462 |
return _clamp_score(grade_docx(task, output_path))
|
| 463 |
if family == "pptx":
|
|
|
|
| 253 |
type, None color, exception) becomes None. Two None values on the same
|
| 254 |
key match; one None vs one non-None counts as a mismatch."""
|
| 255 |
style: Dict[str, Any] = {
|
| 256 |
+
"fill_rgb": None, # solid fill RGB hex (str)
|
| 257 |
+
"fill_theme": None, # theme color name (str) — captures gold-uses-theme tasks
|
| 258 |
+
"line_rgb": None, # line/border RGB hex (str)
|
| 259 |
+
"font_name": None, # first run, str
|
| 260 |
+
"font_size_pt": None, # first run, float
|
| 261 |
+
"font_bold": None, # first run, bool/None (None = inherited)
|
| 262 |
+
"font_italic": None, # first run, bool/None
|
| 263 |
+
"font_rgb": None, # first run, RGB hex (str)
|
| 264 |
+
"para_alignment": None, # first paragraph: PP_ALIGN.CENTER, LEFT, RIGHT, JUSTIFY (str)
|
| 265 |
}
|
| 266 |
# ---- shape fill / line colors ----
|
| 267 |
+
# Capture both explicit RGB and theme-color reference: a "match colors to
|
| 268 |
+
# theme" task changes ad-hoc RGB to theme references, and we want to see
|
| 269 |
+
# that as a real difference rather than letting both sides become None.
|
| 270 |
try:
|
| 271 |
from pptx.enum.dml import MSO_FILL_TYPE
|
| 272 |
+
ft = shape.fill.type
|
| 273 |
+
if ft == MSO_FILL_TYPE.SOLID:
|
| 274 |
+
try:
|
| 275 |
+
style["fill_rgb"] = str(shape.fill.fore_color.rgb)
|
| 276 |
+
except Exception:
|
| 277 |
+
# solid fill but rgb raises → it's a theme color
|
| 278 |
+
try:
|
| 279 |
+
style["fill_theme"] = str(shape.fill.fore_color.theme_color)
|
| 280 |
+
except Exception:
|
| 281 |
+
pass
|
| 282 |
except Exception:
|
| 283 |
pass
|
| 284 |
try:
|
| 285 |
style["line_rgb"] = str(shape.line.color.rgb)
|
| 286 |
except Exception:
|
| 287 |
pass
|
| 288 |
+
# ---- first-paragraph + first-run text properties ----
|
| 289 |
try:
|
| 290 |
if shape.has_text_frame:
|
| 291 |
tf = shape.text_frame
|
| 292 |
+
if tf.paragraphs:
|
| 293 |
+
p0 = tf.paragraphs[0]
|
| 294 |
+
# Paragraph-level alignment (LEFT/CENTER/RIGHT/JUSTIFY) —
|
| 295 |
+
# critical for "center the title", "right-align" tasks.
|
|
|
|
|
|
|
|
|
|
| 296 |
try:
|
| 297 |
+
style["para_alignment"] = str(p0.alignment) if p0.alignment is not None else None
|
| 298 |
except Exception:
|
| 299 |
pass
|
| 300 |
+
if p0.runs:
|
| 301 |
+
run = p0.runs[0]
|
| 302 |
+
style["font_name"] = run.font.name
|
| 303 |
+
if run.font.size is not None:
|
| 304 |
+
style["font_size_pt"] = float(run.font.size.pt)
|
| 305 |
+
style["font_bold"] = run.font.bold
|
| 306 |
+
style["font_italic"] = run.font.italic
|
| 307 |
+
try:
|
| 308 |
+
style["font_rgb"] = str(run.font.color.rgb)
|
| 309 |
+
except Exception:
|
| 310 |
+
pass
|
| 311 |
except Exception:
|
| 312 |
pass
|
| 313 |
return style
|
|
|
|
| 364 |
|
| 365 |
|
| 366 |
_STYLE_WEIGHTS = {
|
| 367 |
+
"fill_rgb": 0.22, # most often-edited attribute in styling tasks
|
| 368 |
+
"fill_theme": 0.08, # NEW: theme-color reference (catches "match colors to theme")
|
| 369 |
+
"line_rgb": 0.08,
|
| 370 |
+
"font_name": 0.08,
|
| 371 |
+
"font_size_pt": 0.12,
|
| 372 |
+
"font_bold": 0.05,
|
| 373 |
+
"font_italic": 0.05,
|
| 374 |
+
"font_rgb": 0.17,
|
| 375 |
+
"para_alignment": 0.15, # NEW: catches "center the title" and friends
|
| 376 |
}
|
| 377 |
|
| 378 |
|
|
|
|
| 464 |
return round(0.2 * slide_score + 0.8 * avg_shape, 4)
|
| 465 |
|
| 466 |
|
| 467 |
+
def _same_bytes(a: str, b: str) -> bool:
|
| 468 |
+
"""True iff two files exist and have identical SHA-256. Used to detect
|
| 469 |
+
'submitted source unchanged' exploits — a model that bypasses the work
|
| 470 |
+
by handing back the input file."""
|
| 471 |
+
import hashlib
|
| 472 |
+
try:
|
| 473 |
+
ah = hashlib.sha256(open(a, "rb").read()).hexdigest()
|
| 474 |
+
bh = hashlib.sha256(open(b, "rb").read()).hexdigest()
|
| 475 |
+
return ah == bh
|
| 476 |
+
except Exception:
|
| 477 |
+
return False
|
| 478 |
+
|
| 479 |
+
|
| 480 |
def grade_task(task: Dict[str, Any], answer: str = "", output_path: str = "") -> float:
|
| 481 |
"""Grade a task. Returns score in (0.001, 0.999).
|
| 482 |
|
| 483 |
For QA tasks: uses *answer* (text) vs task["reference_answer"].
|
| 484 |
For MODIFY tasks (xlsx): cell-diff against task["reference_file"].
|
| 485 |
For MODIFY tasks (docx): validity + diff + OSWorld evaluator.
|
| 486 |
+
For MODIFY tasks (pptx): validity + slide-count + per-shape composite.
|
| 487 |
"""
|
| 488 |
task_type = task.get("task_type", "QA")
|
| 489 |
family = task.get("family", "xlsx")
|
|
|
|
| 494 |
elif task_type == "MODIFY":
|
| 495 |
if not output_path or not Path(output_path).exists():
|
| 496 |
return 0.001
|
| 497 |
+
|
| 498 |
+
# ANTI-EXPLOIT: detect "submitted source unchanged". A model that
|
| 499 |
+
# discovers a task where source-vs-gold scores high (e.g., a small
|
| 500 |
+
# alignment edit on a 55-shape deck) can game the diff by handing
|
| 501 |
+
# back the input file. We refuse to credit byte-identical
|
| 502 |
+
# submissions UNLESS the task is OSWorld's `infeasible` sentinel
|
| 503 |
+
# (where not-modifying is the correct answer).
|
| 504 |
+
src = task.get("source_file", "")
|
| 505 |
+
is_infeasible = False
|
| 506 |
+
ev = task.get("evaluator") or {}
|
| 507 |
+
for c in ev.get("checks") or []:
|
| 508 |
+
if c.get("func") == "infeasible":
|
| 509 |
+
is_infeasible = True
|
| 510 |
+
break
|
| 511 |
+
if src and Path(src).exists() and not is_infeasible:
|
| 512 |
+
if _same_bytes(output_path, src):
|
| 513 |
+
return 0.001
|
| 514 |
+
|
| 515 |
if family == "docx":
|
| 516 |
return _clamp_score(grade_docx(task, output_path))
|
| 517 |
if family == "pptx":
|
|
@@ -492,9 +492,20 @@ def model_slug(name: str) -> str:
|
|
| 492 |
|
| 493 |
|
| 494 |
async def async_main(args: argparse.Namespace) -> None:
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
if not api_key:
|
| 497 |
-
print("ERROR:
|
| 498 |
sys.exit(1)
|
| 499 |
|
| 500 |
# Pick tasks
|
|
|
|
| 492 |
|
| 493 |
|
| 494 |
async def async_main(args: argparse.Namespace) -> None:
|
| 495 |
+
# Pick the API key based on the api-base URL so you don't have to alias
|
| 496 |
+
# env vars when switching providers. Provider-specific env wins; falls back
|
| 497 |
+
# to a generic chain if nothing matches.
|
| 498 |
+
if "nebius" in args.api_base:
|
| 499 |
+
_envs = ("NEBIUS_API_KEY", "API_KEY", "HF_TOKEN")
|
| 500 |
+
elif "huggingface" in args.api_base or "hf.co" in args.api_base:
|
| 501 |
+
_envs = ("HF_TOKEN", "API_KEY", "NEBIUS_API_KEY")
|
| 502 |
+
elif "openai" in args.api_base:
|
| 503 |
+
_envs = ("OPENAI_API_KEY", "API_KEY", "HF_TOKEN")
|
| 504 |
+
else:
|
| 505 |
+
_envs = ("API_KEY", "HF_TOKEN", "NEBIUS_API_KEY", "OPENAI_API_KEY")
|
| 506 |
+
api_key = next((os.environ[k] for k in _envs if os.environ.get(k)), None)
|
| 507 |
if not api_key:
|
| 508 |
+
print(f"ERROR: none of {_envs} are set for api_base={args.api_base!r}", file=sys.stderr)
|
| 509 |
sys.exit(1)
|
| 510 |
|
| 511 |
# Pick tasks
|