apingali Claude Opus 4.7 (1M context) commited on
Commit
033a83e
·
1 Parent(s): 60518c1

feat(hf-space): add HuggingFace backend (Gemma/Phi-4) alongside Anthropic

Browse files

Users without an ANTHROPIC_API_KEY can now run the diagnostic via an
open-weight model served through the HuggingFace Inference Providers
API. On a deployed HuggingFace Space, the Space's identity provides
free monthly inference credits, so no credentials are needed at all —
this is the zero-signup path for trying the diagnostic.

What changed:
app.py
- _detect_provider(env) — env-driven dispatcher (MODEL_PROVIDER >
ANTHROPIC_API_KEY > HF_TOKEN/HUGGING_FACE_HUB_TOKEN/SPACE_ID >
anthropic default). Captured at module load as DEFAULT_PROVIDER.
- _call_anthropic(system, user) and _call_huggingface(system, user)
— two interchangeable backends behind a thin _call_model dispatcher.
HF uses huggingface_hub.InferenceClient.chat_completion with
temperature=0.2 to keep JSON output stable on smaller models.
- diagnose() takes a new provider parameter, defaulting to
DEFAULT_PROVIDER. The Gradio UI now has a Model-provider dropdown
so users can A/B the two backends at runtime.
- F14 error message now includes the provider + model name and
suggests switching providers in the dropdown as a fallback action.
- Fixed a nesting bug in the word-count validator that the same
edit pass introduced (MAX check ended up dead code inside the MIN
branch).

requirements.txt
+ huggingface_hub>=0.27

.env.example
Restructured into sections — provider selection (MODEL_PROVIDER),
Anthropic backend (ANTHROPIC_API_KEY, MODEL_ID), HuggingFace backend
(HF_TOKEN, HF_MODEL_ID with tested alternatives documented inline),
validation (MAX_DESCRIPTION_WORDS).

test_diagnose.py
+ 9 _detect_provider tests covering all five branches of the
env-driven dispatch + case-insensitivity + invalid-explicit
fall-through + multiple HF token var names.
+ 3 _call_model dispatch tests using monkeypatch.setitem on the
PROVIDERS dict (no SDK mocks — Principle VII exempts API calls).
All 27 tests pass (15 prior parser + 12 new provider).

specs/004-berkshire-test/contracts/hf-space-interface.md §2
Rewrote §2 to document both backends side by side, including the
provider-selection precedence table, the HF InferenceClient
invocation pattern, tested model choices, the lack of HF prefix
caching, and the unified F14 failure-mode messaging.

specs/004-berkshire-test/tasks.md T037-T039
T037 rationale updated to reflect the extension. T038 and T039
smoke-test instructions updated to cover both providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (4) hide show
  1. .env.example +44 -6
  2. app.py +154 -29
  3. requirements.txt +1 -0
  4. test_diagnose.py +77 -1
.env.example CHANGED
@@ -1,14 +1,52 @@
1
- # Copy to .env (gitignored) and fill in your Anthropic API key for local development.
2
- # On HuggingFace Spaces, set these as Space secrets in the Settings panel.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
 
 
 
 
4
  ANTHROPIC_API_KEY=your-anthropic-api-key-here
5
 
6
- # Optional overrides (defaults in app.py claude-opus-4-7 is what
7
- # produces materially better diagnostic writeups; claude-sonnet-4-6
8
- # is a cost-optimized fallback that should be benchmarked against
9
- # Opus on real submissions before flipping. See research.md R15.)
10
  MODEL_ID=claude-opus-4-7
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  # Word-count cap on the description Textbox. The Gradio validator
13
  # rejects submissions outside 200–MAX_DESCRIPTION_WORDS.
14
  MAX_DESCRIPTION_WORDS=5000
 
1
+ # Copy to .env (gitignored). The Space supports TWO model backends; you
2
+ # only need credentials for whichever one(s) you want to use. On the
3
+ # deployed HuggingFace Space, leave .env empty and set these as Space
4
+ # secrets in the Settings panel instead.
5
+ #
6
+ # ============================================================
7
+ # PROVIDER SELECTION
8
+ # ============================================================
9
+ # Optional. If unset, the app auto-detects based on which credentials
10
+ # are present (see app.py::_detect_provider). Valid values:
11
+ # anthropic — Claude via the Anthropic SDK (best writeup quality)
12
+ # huggingface — Gemma 2 / Phi-4 / Llama-3.3 / Qwen via HF Inference
13
+ # Providers (works with no Anthropic key; free on
14
+ # HF Spaces via the Space's identity)
15
+ # Leave blank for auto-detect.
16
+ # MODEL_PROVIDER=
17
 
18
+ # ============================================================
19
+ # ANTHROPIC BACKEND
20
+ # ============================================================
21
+ # Required for the anthropic backend. Get one at console.anthropic.com.
22
  ANTHROPIC_API_KEY=your-anthropic-api-key-here
23
 
24
+ # Optional. claude-opus-4-7 is the default — produces materially better
25
+ # diagnostic writeups. claude-sonnet-4-6 is a cost-optimized fallback;
26
+ # benchmark before flipping (research.md R15).
 
27
  MODEL_ID=claude-opus-4-7
28
 
29
+ # ============================================================
30
+ # HUGGINGFACE BACKEND
31
+ # ============================================================
32
+ # Optional locally — get one at huggingface.co/settings/tokens. NOT
33
+ # required on a deployed HuggingFace Space (the Space identity is used
34
+ # automatically and includes free monthly inference credits).
35
+ # HF_TOKEN=your-hf-token-here
36
+
37
+ # Optional. Default google/gemma-2-9b-it works well and is widely
38
+ # available on HF Inference Providers. Other tested choices:
39
+ # microsoft/Phi-4-mini-instruct — smaller, faster, decent JSON
40
+ # meta-llama/Llama-3.3-70B-Instruct — slower, very high quality
41
+ # Qwen/Qwen2.5-72B-Instruct — strong on structured output
42
+ # Smaller open models can be looser than Claude on schema adherence;
43
+ # the parser raises MalformedResponseError on bad output and the UI
44
+ # shows a "try again" message rather than crashing.
45
+ # HF_MODEL_ID=google/gemma-2-9b-it
46
+
47
+ # ============================================================
48
+ # VALIDATION
49
+ # ============================================================
50
  # Word-count cap on the description Textbox. The Gradio validator
51
  # rejects submissions outside 200–MAX_DESCRIPTION_WORDS.
52
  MAX_DESCRIPTION_WORDS=5000
app.py CHANGED
@@ -5,8 +5,16 @@ the two-axis Berkshire Test for AI and returns a scored writeup.
5
 
6
  Architecture per specs/004-berkshire-test/contracts/hf-space-interface.md:
7
  - Inputs: a description (200–5000 words) + 3 optional clarifiers.
8
- - One Anthropic call with the system block (REFERENCE_BLOCK) marked
9
- cache_control:ephemeral; subsequent calls hit the 5-minute cache.
 
 
 
 
 
 
 
 
10
  - Output: two Gradio tabs — markdown writeup + raw JSON.
11
 
12
  Engine/Site boundary (Principle VIII): this app lives in gradio-apps/
@@ -171,10 +179,107 @@ def parse_response(raw: str) -> Response:
171
 
172
  ROOT = Path(__file__).parent
173
 
174
- MODEL_ID = os.environ.get("MODEL_ID", "claude-opus-4-7")
 
175
  MAX_DESCRIPTION_WORDS = int(os.environ.get("MAX_DESCRIPTION_WORDS", "5000"))
176
  MIN_DESCRIPTION_WORDS = 200
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  INDUSTRIES = [
179
  "insurance", "banking", "healthcare", "retail", "manufacturing",
180
  "logistics", "agriculture", "energy", "telecom", "media",
@@ -236,10 +341,15 @@ def diagnose(
236
  industry: Optional[str],
237
  scale: Optional[str],
238
  budget: Optional[str],
 
239
  ) -> tuple[str, str]:
240
- """Validate input, call Anthropic with the cached system block, parse
241
- the response, and return (markdown_writeup, raw_json_string) for the
242
- two Gradio tabs.
 
 
 
 
243
 
244
  Per F14 + contract §2, all error paths surface a user-friendly message
245
  in the markdown tab and an empty JSON tab; nothing leaks a stack trace.
@@ -263,6 +373,14 @@ def diagnose(
263
  "",
264
  )
265
 
 
 
 
 
 
 
 
 
266
  user_prompt = (
267
  PROMPT_TEMPLATE
268
  .replace("{{user_input}}", description)
@@ -272,29 +390,15 @@ def diagnose(
272
  )
273
 
274
  try:
275
- # Lazy-import the SDK so test_diagnose.py can import this module
276
- # without requiring the anthropic package at test time.
277
- from anthropic import Anthropic
278
-
279
- client = Anthropic()
280
- resp = client.messages.create(
281
- model=MODEL_ID,
282
- max_tokens=2500,
283
- system=[
284
- {
285
- "type": "text",
286
- "text": SYSTEM_BLOCK,
287
- "cache_control": {"type": "ephemeral"},
288
- }
289
- ],
290
- messages=[{"role": "user", "content": user_prompt}],
291
- )
292
- raw = resp.content[0].text
293
  except Exception as e:
294
- # Anthropic API timeout / rate limit / auth / server / network failure
 
 
295
  return (
296
- f"⚠ The diagnostic call failed ({type(e).__name__}). Try again in a moment, "
297
- f"or shorten your description.",
 
298
  "",
299
  )
300
 
@@ -333,6 +437,11 @@ def build_demo():
333
  """Build and return the Gradio Blocks UI. Called only by __main__."""
334
  import gradio as gr
335
 
 
 
 
 
 
336
  with gr.Blocks(title="The Compounding Test") as demo:
337
  gr.Markdown(
338
  "# The Compounding Test\n\n"
@@ -340,7 +449,10 @@ def build_demo():
340
  "description of your AI initiative (200–5000 words); receive a scored "
341
  "writeup in one of four quadrants — compounder, one-shot win, compounding "
342
  "the wrong thing, or Roman Candle. The framework is at "
343
- "https://www.mile-hi.ai/journal/the-berkshire-test"
 
 
 
344
  )
345
  with gr.Row():
346
  description = gr.Textbox(
@@ -356,6 +468,19 @@ def build_demo():
356
  industry = gr.Dropdown(INDUSTRIES, label="Industry (optional)", value=None)
357
  scale = gr.Dropdown(SCALES, label="Scale (optional)", value=None)
358
  budget = gr.Dropdown(BUDGETS, label="Budget tier (optional)", value=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  submit = gr.Button("Diagnose", variant="primary")
360
  with gr.Tabs():
361
  with gr.Tab("Diagnosis"):
@@ -364,7 +489,7 @@ def build_demo():
364
  json_out = gr.Code(language="json")
365
  submit.click(
366
  diagnose,
367
- inputs=[description, industry, scale, budget],
368
  outputs=[writeup_out, json_out],
369
  )
370
 
 
5
 
6
  Architecture per specs/004-berkshire-test/contracts/hf-space-interface.md:
7
  - Inputs: a description (200–5000 words) + 3 optional clarifiers.
8
+ - Two backends, selectable by env (`MODEL_PROVIDER`) or auto-detected
9
+ from available credentials:
10
+ * anthropic — Claude Opus / Sonnet via the Anthropic SDK;
11
+ system block is `cache_control:ephemeral` so
12
+ subsequent calls hit the 5-minute prefix cache.
13
+ * huggingface — Open models (Gemma 2 9B by default, swappable to
14
+ Phi-4, Llama-3.3, Qwen 2.5, etc.) via the
15
+ huggingface_hub InferenceClient. Works on HF
16
+ Spaces with the Space's free inference credits;
17
+ locally requires HF_TOKEN.
18
  - Output: two Gradio tabs — markdown writeup + raw JSON.
19
 
20
  Engine/Site boundary (Principle VIII): this app lives in gradio-apps/
 
179
 
180
  ROOT = Path(__file__).parent
181
 
182
+ ANTHROPIC_MODEL_ID = os.environ.get("MODEL_ID", "claude-opus-4-7")
183
+ HF_MODEL_ID = os.environ.get("HF_MODEL_ID", "google/gemma-2-9b-it")
184
  MAX_DESCRIPTION_WORDS = int(os.environ.get("MAX_DESCRIPTION_WORDS", "5000"))
185
  MIN_DESCRIPTION_WORDS = 200
186
 
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Provider abstraction (anthropic vs huggingface — selectable at runtime)
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def _detect_provider(env=None) -> str:
194
+ """Pick a model provider from env. Order of precedence:
195
+ 1. Explicit MODEL_PROVIDER (anthropic | huggingface).
196
+ 2. Presence of ANTHROPIC_API_KEY → anthropic.
197
+ 3. Presence of HF_TOKEN / HUGGING_FACE_HUB_TOKEN, or running on
198
+ a HuggingFace Space (SPACE_ID set) → huggingface.
199
+ 4. Fall through to anthropic (call-time error will tell the user
200
+ which env to set).
201
+ """
202
+ env = env if env is not None else os.environ
203
+ explicit = env.get("MODEL_PROVIDER", "").strip().lower()
204
+ if explicit in ("anthropic", "huggingface"):
205
+ return explicit
206
+ if env.get("ANTHROPIC_API_KEY"):
207
+ return "anthropic"
208
+ if (
209
+ env.get("HF_TOKEN")
210
+ or env.get("HUGGING_FACE_HUB_TOKEN")
211
+ or env.get("SPACE_ID")
212
+ ):
213
+ return "huggingface"
214
+ return "anthropic"
215
+
216
+
217
+ def _call_anthropic(system_block: str, user_prompt: str) -> str:
218
+ """Anthropic backend. System block is cache-marked; the user prompt
219
+ is sent fresh. Returns the raw assistant text."""
220
+ from anthropic import Anthropic
221
+
222
+ client = Anthropic()
223
+ resp = client.messages.create(
224
+ model=ANTHROPIC_MODEL_ID,
225
+ max_tokens=2500,
226
+ system=[
227
+ {
228
+ "type": "text",
229
+ "text": system_block,
230
+ "cache_control": {"type": "ephemeral"},
231
+ }
232
+ ],
233
+ messages=[{"role": "user", "content": user_prompt}],
234
+ )
235
+ return resp.content[0].text
236
+
237
+
238
+ def _call_huggingface(system_block: str, user_prompt: str) -> str:
239
+ """HuggingFace backend. Uses the unified chat_completion interface,
240
+ which routes through HF Inference Providers and supports Gemma 2,
241
+ Phi-4-mini-instruct, Llama-3.3, Qwen 2.5, and many others. Lower
242
+ temperature (0.2) than the SDK default to keep JSON output stable —
243
+ smaller open models can be looser than Claude on schema adherence.
244
+ """
245
+ from huggingface_hub import InferenceClient
246
+
247
+ token = (
248
+ os.environ.get("HF_TOKEN")
249
+ or os.environ.get("HUGGING_FACE_HUB_TOKEN")
250
+ )
251
+ client = InferenceClient(model=HF_MODEL_ID, token=token, timeout=120)
252
+ resp = client.chat_completion(
253
+ messages=[
254
+ {"role": "system", "content": system_block},
255
+ {"role": "user", "content": user_prompt},
256
+ ],
257
+ max_tokens=2500,
258
+ temperature=0.2,
259
+ )
260
+ return resp.choices[0].message.content
261
+
262
+
263
+ PROVIDERS = {
264
+ "anthropic": _call_anthropic,
265
+ "huggingface": _call_huggingface,
266
+ }
267
+
268
+
269
+ def _call_model(system_block: str, user_prompt: str, provider: str) -> str:
270
+ """Dispatch to the named provider. Raises ValueError on unknown
271
+ provider; callers are expected to validate before calling."""
272
+ if provider not in PROVIDERS:
273
+ raise ValueError(
274
+ f"Unknown provider: {provider!r}; expected one of {sorted(PROVIDERS)}"
275
+ )
276
+ return PROVIDERS[provider](system_block, user_prompt)
277
+
278
+
279
+ # Auto-detected once at module import; the Gradio UI exposes a runtime
280
+ # override via the Provider dropdown.
281
+ DEFAULT_PROVIDER = _detect_provider()
282
+
283
  INDUSTRIES = [
284
  "insurance", "banking", "healthcare", "retail", "manufacturing",
285
  "logistics", "agriculture", "energy", "telecom", "media",
 
341
  industry: Optional[str],
342
  scale: Optional[str],
343
  budget: Optional[str],
344
+ provider: Optional[str] = None,
345
  ) -> tuple[str, str]:
346
+ """Validate input, call the selected model with the cached system
347
+ block, parse the response, and return (markdown_writeup,
348
+ raw_json_string) for the two Gradio tabs.
349
+
350
+ `provider` (anthropic | huggingface) defaults to DEFAULT_PROVIDER
351
+ when not supplied — the Gradio dropdown always supplies it on a
352
+ real submission.
353
 
354
  Per F14 + contract §2, all error paths surface a user-friendly message
355
  in the markdown tab and an empty JSON tab; nothing leaks a stack trace.
 
373
  "",
374
  )
375
 
376
+ provider = provider or DEFAULT_PROVIDER
377
+ if provider not in PROVIDERS:
378
+ return (
379
+ f"⚠ Unknown model provider {provider!r}. Pick one of "
380
+ f"{sorted(PROVIDERS)} from the dropdown.",
381
+ "",
382
+ )
383
+
384
  user_prompt = (
385
  PROMPT_TEMPLATE
386
  .replace("{{user_input}}", description)
 
390
  )
391
 
392
  try:
393
+ raw = _call_model(SYSTEM_BLOCK, user_prompt, provider)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  except Exception as e:
395
+ # API timeout / rate limit / auth / server / network failure
396
+ # (Anthropic SDK or huggingface_hub InferenceClient).
397
+ model_label = ANTHROPIC_MODEL_ID if provider == "anthropic" else HF_MODEL_ID
398
  return (
399
+ f"⚠ The diagnostic call to {provider} ({model_label}) failed "
400
+ f"({type(e).__name__}). Try again in a moment, switch providers in "
401
+ f"the dropdown, or shorten your description.",
402
  "",
403
  )
404
 
 
437
  """Build and return the Gradio Blocks UI. Called only by __main__."""
438
  import gradio as gr
439
 
440
+ provider_choices = [
441
+ (f"Anthropic — {ANTHROPIC_MODEL_ID} (requires ANTHROPIC_API_KEY)", "anthropic"),
442
+ (f"HuggingFace — {HF_MODEL_ID} (free on HF Spaces; HF_TOKEN locally)", "huggingface"),
443
+ ]
444
+
445
  with gr.Blocks(title="The Compounding Test") as demo:
446
  gr.Markdown(
447
  "# The Compounding Test\n\n"
 
449
  "description of your AI initiative (200–5000 words); receive a scored "
450
  "writeup in one of four quadrants — compounder, one-shot win, compounding "
451
  "the wrong thing, or Roman Candle. The framework is at "
452
+ "https://www.mile-hi.ai/journal/the-berkshire-test\n\n"
453
+ f"_Default model provider: **{DEFAULT_PROVIDER}** "
454
+ f"(auto-detected from your environment — pick a different one in the "
455
+ f"dropdown below to compare)._"
456
  )
457
  with gr.Row():
458
  description = gr.Textbox(
 
468
  industry = gr.Dropdown(INDUSTRIES, label="Industry (optional)", value=None)
469
  scale = gr.Dropdown(SCALES, label="Scale (optional)", value=None)
470
  budget = gr.Dropdown(BUDGETS, label="Budget tier (optional)", value=None)
471
+ with gr.Row():
472
+ provider = gr.Dropdown(
473
+ choices=provider_choices,
474
+ value=DEFAULT_PROVIDER,
475
+ label="Model provider",
476
+ info=(
477
+ "Claude gives the highest-quality writeups but needs your "
478
+ "own ANTHROPIC_API_KEY. The HuggingFace backend runs on a "
479
+ "smaller open-weight model and works on a deployed HF Space "
480
+ "without any keys, so it's the easiest way to try the "
481
+ "diagnostic without signing up for anything."
482
+ ),
483
+ )
484
  submit = gr.Button("Diagnose", variant="primary")
485
  with gr.Tabs():
486
  with gr.Tab("Diagnosis"):
 
489
  json_out = gr.Code(language="json")
490
  submit.click(
491
  diagnose,
492
+ inputs=[description, industry, scale, budget, provider],
493
  outputs=[writeup_out, json_out],
494
  )
495
 
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  gradio>=4.0
2
  anthropic>=0.39
 
3
  python-dotenv>=1.0
4
  pytest>=8.0
 
1
  gradio>=4.0
2
  anthropic>=0.39
3
+ huggingface_hub>=0.27
4
  python-dotenv>=1.0
5
  pytest>=8.0
test_diagnose.py CHANGED
@@ -9,7 +9,13 @@ from __future__ import annotations
9
 
10
  import pytest
11
 
12
- from app import MalformedResponseError, parse_response
 
 
 
 
 
 
13
 
14
 
15
  # --- Fixtures ---------------------------------------------------------------
@@ -168,6 +174,76 @@ def test_extra_unknown_fields_tolerated():
168
  assert r.quadrant == "compounder"
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def test_warnings_populated_for_failure_quadrant():
172
  raw = VALID_JSON_BLOCK.replace('"quadrant": "compounder"', '"quadrant": "roman-candle"').replace(
173
  '"warnings": []',
 
9
 
10
  import pytest
11
 
12
+ from app import (
13
+ MalformedResponseError,
14
+ PROVIDERS,
15
+ _call_model,
16
+ _detect_provider,
17
+ parse_response,
18
+ )
19
 
20
 
21
  # --- Fixtures ---------------------------------------------------------------
 
174
  assert r.quadrant == "compounder"
175
 
176
 
177
+ # --- Provider auto-detection (multi-backend support) ----------------------
178
+
179
+
180
+ def test_detect_provider_explicit_anthropic_wins():
181
+ env = {"MODEL_PROVIDER": "anthropic", "HF_TOKEN": "hf-xxx"}
182
+ assert _detect_provider(env) == "anthropic"
183
+
184
+
185
+ def test_detect_provider_explicit_huggingface_wins():
186
+ env = {"MODEL_PROVIDER": "huggingface", "ANTHROPIC_API_KEY": "sk-xxx"}
187
+ assert _detect_provider(env) == "huggingface"
188
+
189
+
190
+ def test_detect_provider_case_insensitive():
191
+ assert _detect_provider({"MODEL_PROVIDER": "HuggingFace"}) == "huggingface"
192
+
193
+
194
+ def test_detect_provider_invalid_explicit_falls_through():
195
+ # bogus MODEL_PROVIDER is ignored; auto-detect kicks in
196
+ env = {"MODEL_PROVIDER": "bogus", "ANTHROPIC_API_KEY": "sk-xxx"}
197
+ assert _detect_provider(env) == "anthropic"
198
+
199
+
200
+ def test_detect_provider_anthropic_when_only_anthropic_key_set():
201
+ assert _detect_provider({"ANTHROPIC_API_KEY": "sk-xxx"}) == "anthropic"
202
+
203
+
204
+ def test_detect_provider_huggingface_when_only_hf_token_set():
205
+ assert _detect_provider({"HF_TOKEN": "hf-xxx"}) == "huggingface"
206
+
207
+
208
+ def test_detect_provider_huggingface_when_running_on_hf_space():
209
+ # HF Spaces sets SPACE_ID automatically and provides free inference credits
210
+ assert _detect_provider({"SPACE_ID": "mile-hi-ai/compounding-test"}) == "huggingface"
211
+
212
+
213
+ def test_detect_provider_alt_hf_token_var():
214
+ # HuggingFace SDKs also recognize HUGGING_FACE_HUB_TOKEN
215
+ assert _detect_provider({"HUGGING_FACE_HUB_TOKEN": "hf-xxx"}) == "huggingface"
216
+
217
+
218
+ def test_detect_provider_default_when_nothing_set():
219
+ # No creds anywhere → default to anthropic (clearest error at call time)
220
+ assert _detect_provider({}) == "anthropic"
221
+
222
+
223
+ # --- Provider dispatch (_call_model routes to the right backend) -----------
224
+
225
+
226
+ def test_call_model_routes_to_anthropic_backend(monkeypatch):
227
+ calls = []
228
+ monkeypatch.setitem(PROVIDERS, "anthropic", lambda s, u: (calls.append(("anthropic", s, u)) or "anth-out"))
229
+ out = _call_model("system-text", "user-text", "anthropic")
230
+ assert out == "anth-out"
231
+ assert calls == [("anthropic", "system-text", "user-text")]
232
+
233
+
234
+ def test_call_model_routes_to_huggingface_backend(monkeypatch):
235
+ calls = []
236
+ monkeypatch.setitem(PROVIDERS, "huggingface", lambda s, u: (calls.append(("hf", s, u)) or "hf-out"))
237
+ out = _call_model("system-text", "user-text", "huggingface")
238
+ assert out == "hf-out"
239
+ assert calls == [("hf", "system-text", "user-text")]
240
+
241
+
242
+ def test_call_model_unknown_provider_raises():
243
+ with pytest.raises(ValueError, match="provider"):
244
+ _call_model("s", "u", "bogus-provider")
245
+
246
+
247
  def test_warnings_populated_for_failure_quadrant():
248
  raw = VALID_JSON_BLOCK.replace('"quadrant": "compounder"', '"quadrant": "roman-candle"').replace(
249
  '"warnings": []',