township: give the tool an API key to talk to CoderAI with (v0.2.48)

Training a video LoRA from the Characters page returned:

  POST /v1/loras/train -> 401: Invalid API key. Provide a valid Bearer token.

CoderAI gates every /v1 endpoint through the same dependency and makes NO
exemption for loopback, so the bundled tools — which call 127.0.0.1 from inside
the same container — need a Bearer token exactly like a remote client. The
container launches this tool without --api-key and its saved config had none, so
there was no token to send. Nothing was wrong with the training request itself.

Fourteen call sites each built their client from default_args.api_key directly.
They now go through one resolver with a stated order: the running options (set by
--api-key, a saved config, or the Connection card) → the config file on disk →
CODERAI_API_KEY in the environment, which is how a launcher can supply it without
a config edit. A blank value is treated as absent rather than sent as "Bearer ",
which would have failed in a more confusing way.

A 401 now says what to do — where to put a key in THIS tool — instead of echoing
a server message that tells an operator nothing about this UI.

The immediate fix needs no rebuild: paste the key into the Connection card on the
Run page and press Save config. That writes township_config.json and updates the
running options, so the next action authenticates.

11 tests, including that no call site reads default_args.api_key any more.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 24caf0e8
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.2.47" __version__ = "0.2.48"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
"""Where the township tool gets its CoderAI Bearer token.
CoderAI gates every /v1 endpoint and makes no exemption for loopback, so the
bundled tools — which call 127.0.0.1 from inside the same container — need a token
exactly like a remote client. The container launches this tool WITHOUT --api-key,
so "train a video LoRA" came back as:
POST /v1/loras/train -> 401: Invalid API key. Provide a valid Bearer token.
Fourteen call sites built their client straight from default_args.api_key, which
was None. They now go through one resolver with a defined order.
"""
import argparse
import importlib.util
import json
import pathlib
import sys
import pytest
ROOT = pathlib.Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location(
"tw_key", ROOT / "tools" / "gen_township_fighters.py")
TW = importlib.util.module_from_spec(_spec)
sys.modules["tw_key"] = TW
_spec.loader.exec_module(TW)
def _args(**kw):
kw.setdefault("api_key", None)
kw.setdefault("config", None)
kw.setdefault("out_dir", None)
return argparse.Namespace(**kw)
def test_the_running_options_win(tmp_path, monkeypatch):
(tmp_path / "township_config.json").write_text(json.dumps({"api_key": "on-disk"}))
monkeypatch.setenv("CODERAI_API_KEY", "in-env")
assert TW.resolve_api_key(_args(api_key="live", out_dir=str(tmp_path))) == "live"
def test_a_saved_config_is_used_when_nothing_was_passed(tmp_path):
"""The Connection card writes here, so pasting a key and pressing Save config
is enough — no restart, no CLI flag."""
(tmp_path / "township_config.json").write_text(json.dumps({"api_key": "on-disk"}))
assert TW.resolve_api_key(_args(out_dir=str(tmp_path))) == "on-disk"
def test_an_explicit_config_path_is_honoured(tmp_path):
cfg = tmp_path / "elsewhere.json"
cfg.write_text(json.dumps({"api_key": "explicit"}))
assert TW.resolve_api_key(_args(config=str(cfg))) == "explicit"
def test_the_environment_is_the_last_resort(tmp_path, monkeypatch):
"""How a launcher can supply the token without editing a config."""
monkeypatch.setenv("CODERAI_API_KEY", "in-env")
assert TW.resolve_api_key(_args(out_dir=str(tmp_path))) == "in-env"
def test_no_key_anywhere_is_none_not_an_empty_string(tmp_path, monkeypatch):
"""An empty string would set 'Authorization: Bearer ' and fail differently."""
monkeypatch.delenv("CODERAI_API_KEY", raising=False)
assert TW.resolve_api_key(_args(out_dir=str(tmp_path))) is None
assert TW.resolve_api_key(None) is None
@pytest.mark.parametrize("value", ["", " ", None])
def test_a_blank_key_is_not_treated_as_a_key(tmp_path, monkeypatch, value):
monkeypatch.delenv("CODERAI_API_KEY", raising=False)
(tmp_path / "township_config.json").write_text(json.dumps({"api_key": value}))
assert TW.resolve_api_key(_args(api_key=value, out_dir=str(tmp_path))) is None
def test_an_unreadable_config_does_not_crash(tmp_path, monkeypatch):
monkeypatch.delenv("CODERAI_API_KEY", raising=False)
(tmp_path / "township_config.json").write_text("{ not json")
assert TW.resolve_api_key(_args(out_dir=str(tmp_path))) is None
def test_every_client_goes_through_the_resolver():
"""The bug was 14 call sites each reading default_args.api_key directly."""
src = (ROOT / "tools" / "gen_township_fighters.py").read_text(encoding="utf-8")
assert 'getattr(default_args, "api_key", None))' not in src
assert "getattr(default_args, 'api_key', None)," not in src
assert src.count("resolve_api_key(default_args)") >= 14
def test_a_401_says_what_to_do_about_it():
"""The raw body ("Invalid API key. Provide a valid Bearer token.") tells an
operator nothing about WHERE to put one in this tool."""
src = (ROOT / "tools" / "gen_township_fighters.py").read_text(encoding="utf-8")
assert "has no API key" in src
assert "Save config" in src
assert "CODERAI_API_KEY" in src
...@@ -813,6 +813,42 @@ def _is_fatal(err: Exception) -> bool: ...@@ -813,6 +813,42 @@ def _is_fatal(err: Exception) -> bool:
# CoderAI API client # CoderAI API client
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def resolve_api_key(args=None, out_dir=None):
"""The Bearer token to talk to CoderAI with, from the first place that has one.
CoderAI gates every /v1 endpoint the same way, with no exemption for loopback —
so the bundled tools, which call 127.0.0.1 from inside the same container, need
a token exactly like a remote client does. The container launches this tool
without --api-key, which is why "train a video LoRA" came back as a bare 401.
Order: the running options (set by --api-key, by a saved config, or by the
Connection card) → the config file on disk → CODERAI_API_KEY in the
environment, which is how a launcher can supply it without a config edit.
"""
key = (getattr(args, "api_key", None) or "").strip() if args is not None else ""
if key:
return key
candidates = []
if args is not None:
if getattr(args, "config", None):
candidates.append(getattr(args, "config"))
if getattr(args, "out_dir", None):
candidates.append(Path(args.out_dir) / "township_config.json")
if out_dir:
candidates.append(Path(out_dir) / "township_config.json")
for candidate in candidates:
if not candidate:
continue
try:
with open(candidate, encoding="utf-8") as fh:
key = (json.load(fh).get("api_key") or "").strip()
if key:
return key
except (OSError, ValueError, TypeError):
pass
return (os.environ.get("CODERAI_API_KEY") or "").strip() or None
class CoderAIClient: class CoderAIClient:
def __init__(self, base_url: str, api_key: Optional[str] = None, timeout: int = 7200): def __init__(self, base_url: str, api_key: Optional[str] = None, timeout: int = 7200):
self.base = base_url.rstrip("/") self.base = base_url.rstrip("/")
...@@ -824,6 +860,12 @@ class CoderAIClient: ...@@ -824,6 +860,12 @@ class CoderAIClient:
def _post(self, path: str, body: dict, timeout: int = None) -> dict: def _post(self, path: str, body: dict, timeout: int = None) -> dict:
r = self.session.post(f"{self.base}{path}", json=body, r = self.session.post(f"{self.base}{path}", json=body,
timeout=timeout if timeout is not None else self.timeout) timeout=timeout if timeout is not None else self.timeout)
if r.status_code == 401:
raise RuntimeError(
f"POST {path} → 401: CoderAI has authentication enabled and this tool "
f"has no API key. Put one in the Connection card on the Run page and "
f"press 'Save config' (or start it with --api-key, or set "
f"CODERAI_API_KEY).")
if not r.ok: if not r.ok:
raise RuntimeError(f"POST {path} → {r.status_code}: {r.text[:400]}") raise RuntimeError(f"POST {path} → {r.status_code}: {r.text[:400]}")
return r.json() return r.json()
...@@ -5524,7 +5566,7 @@ def launch_web_ui(default_args): ...@@ -5524,7 +5566,7 @@ def launch_web_ui(default_args):
stem = fpath.stem stem = fpath.stem
suffix = fpath.suffix suffix = fpath.suffix
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, 'api_key', None), resolve_api_key(default_args),
timeout=7200) timeout=7200)
def _set_progress(pct, msg=""): def _set_progress(pct, msg=""):
...@@ -5643,7 +5685,7 @@ def launch_web_ui(default_args): ...@@ -5643,7 +5685,7 @@ def launch_web_ui(default_args):
size, _ref_steps = _ref_gen_res_steps(default_args) size, _ref_steps = _ref_gen_res_steps(default_args)
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
_prog(8, "selecting image model…") _prog(8, "selecting image model…")
model = getattr(default_args, "image_model", None) model = getattr(default_args, "image_model", None)
if not model: if not model:
...@@ -5742,7 +5784,7 @@ def launch_web_ui(default_args): ...@@ -5742,7 +5784,7 @@ def launch_web_ui(default_args):
# Honour the Run-page resolution + steps (keyframe_size/keyframe_steps). # Honour the Run-page resolution + steps (keyframe_size/keyframe_steps).
size, _ref_steps = _ref_gen_res_steps(default_args) size, _ref_steps = _ref_gen_res_steps(default_args)
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
_prog(8, "selecting image model…") _prog(8, "selecting image model…")
model = getattr(default_args, "image_model", None) model = getattr(default_args, "image_model", None)
if not model: if not model:
...@@ -5896,7 +5938,7 @@ def launch_web_ui(default_args): ...@@ -5896,7 +5938,7 @@ def launch_web_ui(default_args):
# ── Prompts-only: write the fight-clip + outcome prompts and stop. ──── # ── Prompts-only: write the fight-clip + outcome prompts and stop. ────
_prog(8, "preparing text model…") _prog(8, "preparing text model…")
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
text_model = None text_model = None
if not getattr(default_args, "no_llm", False): if not getattr(default_args, "no_llm", False):
try: try:
...@@ -5987,7 +6029,7 @@ def launch_web_ui(default_args): ...@@ -5987,7 +6029,7 @@ def launch_web_ui(default_args):
try: try:
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
_prog(4, f"selecting {'video' if is_video else 'image'} model…") _prog(4, f"selecting {'video' if is_video else 'image'} model…")
if is_video: if is_video:
model = getattr(default_args, "video_model", None) model = getattr(default_args, "video_model", None)
...@@ -6356,7 +6398,7 @@ def launch_web_ui(default_args): ...@@ -6356,7 +6398,7 @@ def launch_web_ui(default_args):
return return
_prog(8, "preparing text model…") _prog(8, "preparing text model…")
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
# Auto-select a text model like a fresh run (config text_model is # Auto-select a text model like a fresh run (config text_model is
# usually null) so prompts are LLM-generated, not static templates. # usually null) so prompts are LLM-generated, not static templates.
text_model = None text_model = None
...@@ -6437,7 +6479,7 @@ def launch_web_ui(default_args): ...@@ -6437,7 +6479,7 @@ def launch_web_ui(default_args):
return return
_prog(8, "preparing text model…") _prog(8, "preparing text model…")
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
text_model = None text_model = None
if not getattr(default_args, "no_llm", False): if not getattr(default_args, "no_llm", False):
try: try:
...@@ -6484,7 +6526,7 @@ def launch_web_ui(default_args): ...@@ -6484,7 +6526,7 @@ def launch_web_ui(default_args):
return return
_prog(8, "preparing text model…") _prog(8, "preparing text model…")
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
text_model = None text_model = None
if not getattr(default_args, "no_llm", False): if not getattr(default_args, "no_llm", False):
try: try:
...@@ -6558,7 +6600,7 @@ def launch_web_ui(default_args): ...@@ -6558,7 +6600,7 @@ def launch_web_ui(default_args):
return return
_prog(8, "preparing text model…") _prog(8, "preparing text model…")
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
text_model = None text_model = None
if not getattr(default_args, "no_llm", False): if not getattr(default_args, "no_llm", False):
try: try:
...@@ -6600,7 +6642,7 @@ def launch_web_ui(default_args): ...@@ -6600,7 +6642,7 @@ def launch_web_ui(default_args):
# ── Render scopes: need the video model + consistency settings ───── # ── Render scopes: need the video model + consistency settings ─────
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
_prog(5, "selecting video model…") _prog(5, "selecting video model…")
video_model = getattr(default_args, "video_model", None) video_model = getattr(default_args, "video_model", None)
if not video_model: if not video_model:
...@@ -8430,7 +8472,7 @@ fetch('/status').then(r=>r.json()).then(d=>{{ ...@@ -8430,7 +8472,7 @@ fetch('/status').then(r=>r.json()).then(d=>{{
return _video_model_cache["id"] return _video_model_cache["id"]
try: try:
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
vm = pick_model(client, "video", None) vm = pick_model(client, "video", None)
except Exception: except Exception:
vm = "" vm = ""
...@@ -10742,7 +10784,7 @@ async function resetPrompts(ev){ ...@@ -10742,7 +10784,7 @@ async function resetPrompts(ev){
_j.dumps({"error": "invalid kind"})); return _j.dumps({"error": "invalid kind"})); return
existing = [p["name"] for p in _list_profiles(kind)] existing = [p["name"] for p in _list_profiles(kind)]
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
text_model = None text_model = None
if not getattr(default_args, "no_llm", False): if not getattr(default_args, "no_llm", False):
try: try:
...@@ -11508,7 +11550,7 @@ async function resetPrompts(ev){ ...@@ -11508,7 +11550,7 @@ async function resetPrompts(ev){
synced = True synced = True
try: try:
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
client.patch_profile(kind, name, add_images=added_uris) client.patch_profile(kind, name, add_images=added_uris)
except Exception: except Exception:
synced = False synced = False
...@@ -11545,7 +11587,7 @@ async function resetPrompts(ev){ ...@@ -11545,7 +11587,7 @@ async function resetPrompts(ev){
base = out_dir / (kind + "s") / name base = out_dir / (kind + "s") / name
client = CoderAIClient(default_args.base_url, client = CoderAIClient(default_args.base_url,
getattr(default_args, "api_key", None)) resolve_api_key(default_args))
if path == "/profile/delete": if path == "/profile/delete":
try: try:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment