pebaryan commited on
Commit
689c526
·
1 Parent(s): 2706be4

Fix: use huggingface_hub InferenceClient as default backend

Browse files

- New hf_llm.py: HFLLM/HFLLMPool wrappers using InferenceClient
(reliable on HF Spaces, unlike raw OpenAI client DNS resolution)
- build_pool auto-selects HFLLMPool for HF defaults, LLMPool for custom
- Defaults: BIG=Qwen3.5-27B, SMALL=Qwen3-8B (confirmed working!)
- Settings tab: paste model IDs for HF, or full URLs for custom endpoints

Files changed (4) hide show
  1. .env.example +3 -3
  2. app.py +83 -48
  3. hf_llm.py +99 -0
  4. requirements.txt +1 -0
.env.example CHANGED
@@ -2,14 +2,14 @@
2
  # Copy to .env and fill in your values.
3
 
4
  # --- LLM BIG (sintesis, drafting) ---
5
- # Default: Hugging Face Inference API (Qwen3-32B)
6
- LLM_BIG_URL=https://api-inference.huggingface.co/models/Qwen/Qwen3-32B/v1
7
  LLM_BIG_API_KEY=
8
  LLM_BIG_MODEL=qwen3
9
 
10
  # --- LLM SMALL (klasifikasi, ekstraksi) ---
11
  # Default: Hugging Face Inference API (Qwen3-8B)
12
- LLM_SMALL_URL=https://api-inference.huggingface.co/models/Qwen/Qwen3-8B/v1
13
  LLM_SMALL_API_KEY=
14
  LLM_SMALL_MODEL=qwen3
15
 
 
2
  # Copy to .env and fill in your values.
3
 
4
  # --- LLM BIG (sintesis, drafting) ---
5
+ # Default: Hugging Face Inference API (Qwen3.5-27B)
6
+ HF_BIG_MODEL=Qwen/Qwen3.5-27B
7
  LLM_BIG_API_KEY=
8
  LLM_BIG_MODEL=qwen3
9
 
10
  # --- LLM SMALL (klasifikasi, ekstraksi) ---
11
  # Default: Hugging Face Inference API (Qwen3-8B)
12
+ HF_SMALL_MODEL=Qwen/Qwen3-8B
13
  LLM_SMALL_API_KEY=
14
  LLM_SMALL_MODEL=qwen3
15
 
app.py CHANGED
@@ -20,29 +20,32 @@ if _src.exists() and str(_src) not in sys.path:
20
  import gradio as gr
21
 
22
  from legawa.agents import analis_ruu, peneliti, penyusun, surat
23
- from legawa.config import LLMConfig, Settings
24
- from legawa.llm import LLMPool
25
  from legawa.tools.cache import CachingPasalClient
26
  from legawa.tools.pasal import PasalClient
27
 
28
  # ── Default HF Inference API config (zero-config demo) ──────────────────
29
- # These map to HF's free Inference API, which is OpenAI-compatible.
30
- # Users can override via the Settings tab or by setting env vars on the Space.
31
- HF_BIG_URL = os.environ.get(
32
- "HF_BIG_URL",
33
- "https://api-inference.huggingface.co/models/Qwen/Qwen3-32B/v1",
34
- )
35
- HF_SMALL_URL = os.environ.get(
36
- "HF_SMALL_URL",
37
- "https://api-inference.huggingface.co/models/Qwen/Qwen3-8B/v1",
38
- )
39
- # HF Inference API doesn't require a token for free-tier browsing, but
40
- # setting HF_TOKEN as a Space secret bumps your rate limit significantly.
41
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
42
 
43
  BUILD_INFO = "Build Small Hackathon 2026 · legawa v0.1"
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  # ── Bootstrap: create settings + pool given user overrides ──────────────
47
  def build_pool(
48
  big_url: str = "",
@@ -55,11 +58,12 @@ def build_pool(
55
  temperature: float = 0.3,
56
  max_tokens: int = 4096,
57
  strict_citations: bool = True,
58
- ) -> tuple[LLMPool, CachingPasalClient]:
59
- """Build an LLMPool + CachingPasalClient from user-provided overrides.
60
 
 
 
61
  Falls through to env vars / HF defaults for anything left blank.
62
- Does NOT call load_settings() — which requires env vars set on HF Space.
63
  """
64
  from datetime import date
65
 
@@ -67,43 +71,74 @@ def build_pool(
67
  pasal_token = pasal_token or os.environ.get("PASAL_API_TOKEN", "")
68
 
69
  # Resolve BIG endpoint: user input → env var → HF default
70
- resolved_big_url = big_url or os.environ.get("LLM_BIG_URL", HF_BIG_URL)
71
  resolved_big_key = big_key or os.environ.get("LLM_BIG_API_KEY", HF_TOKEN)
72
- resolved_big_model = big_model or os.environ.get("LLM_BIG_MODEL", "qwen3")
73
 
74
  # Resolve SMALL endpoint: user input → env var → HF default
75
- resolved_small_url = small_url or os.environ.get("LLM_SMALL_URL", HF_SMALL_URL)
76
  resolved_small_key = small_key or os.environ.get("LLM_SMALL_API_KEY", HF_TOKEN)
77
- resolved_small_model = small_model or os.environ.get("LLM_SMALL_MODEL", "qwen3")
78
-
79
- big_cfg = LLMConfig(
80
- base_url=resolved_big_url,
81
- api_key=resolved_big_key,
82
- model=resolved_big_model,
83
- temperature=temperature,
84
- max_tokens=max_tokens,
85
- )
86
- small_cfg = LLMConfig(
87
- base_url=resolved_small_url,
88
- api_key=resolved_small_key,
89
- model=resolved_small_model,
90
- temperature=temperature,
91
- max_tokens=max_tokens,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  )
 
 
 
93
 
94
- override_settings = Settings(
 
 
 
 
95
  pasal_token=pasal_token,
96
  pasal_base_url=os.environ.get("PASAL_BASE_URL", "https://pasal.id/api/v1"),
97
- big=big_cfg,
98
- small=small_cfg,
99
- run_date=os.environ.get("LEGAWA_RUN_DATE", date.today().isoformat()),
100
- corpus_watermark=os.environ.get("PASAL_CORPUS_WATERMARK", ""),
101
- strict_citations=strict_citations,
102
  )
103
 
104
- pool = LLMPool(override_settings)
105
- raw = PasalClient(override_settings)
106
- pasal = CachingPasalClient(raw)
107
  return pool, pasal
108
 
109
 
@@ -318,9 +353,9 @@ def build_app() -> gr.Blocks:
318
  )
319
 
320
  # ── Hidden state for connection config shared across tabs ──────
321
- big_url = gr.Textbox(label="BIG LLM URL", value=HF_BIG_URL, visible=False)
322
  big_key = gr.Textbox(label="BIG LLM API Key", value=HF_TOKEN, visible=False)
323
- small_url = gr.Textbox(label="SMALL LLM URL", value=HF_SMALL_URL, visible=False)
324
  small_key = gr.Textbox(label="SMALL LLM API Key", value=HF_TOKEN, visible=False)
325
  pasal_token = gr.Textbox(
326
  label="pasal.id Token",
@@ -467,7 +502,7 @@ def build_app() -> gr.Blocks:
467
  )
468
  with gr.Group():
469
  gr.Markdown("### 🧠 LLM BIG (sintesis, drafting)")
470
- s_big_url = gr.Textbox(label="URL", value=HF_BIG_URL)
471
  s_big_key = gr.Textbox(
472
  label="API Key",
473
  type="password",
@@ -479,7 +514,7 @@ def build_app() -> gr.Blocks:
479
  )
480
  with gr.Group():
481
  gr.Markdown("### 🧠 LLM SMALL (klasifikasi, ekstraksi)")
482
- s_small_url = gr.Textbox(label="URL", value=HF_SMALL_URL)
483
  s_small_key = gr.Textbox(
484
  label="API Key",
485
  type="password",
 
20
  import gradio as gr
21
 
22
  from legawa.agents import analis_ruu, peneliti, penyusun, surat
 
 
23
  from legawa.tools.cache import CachingPasalClient
24
  from legawa.tools.pasal import PasalClient
25
 
26
  # ── Default HF Inference API config (zero-config demo) ──────────────────
27
+ # Uses huggingface_hub's InferenceClient (works reliably on HF Spaces).
28
+ # Users can override via the Settings tab to use custom endpoints.
29
+ HF_BIG_MODEL = os.environ.get("HF_BIG_MODEL", "Qwen/Qwen3.5-27B")
30
+ HF_SMALL_MODEL = os.environ.get("HF_SMALL_MODEL", "Qwen/Qwen3-8B")
 
 
 
 
 
 
 
 
31
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
32
 
33
  BUILD_INFO = "Build Small Hackathon 2026 · legawa v0.1"
34
 
35
 
36
+ def _is_hf_default(url: str) -> bool:
37
+ """Check if a URL is a default HF Inference API endpoint."""
38
+ return "huggingface.co/models/" in url
39
+
40
+
41
+ def _model_id_from_url(url: str) -> str:
42
+ """Extract model ID from HF Inference API URL."""
43
+ # URL format: https://api-inference.huggingface.co/models/{model_id}/v1
44
+ if "/models/" in url:
45
+ return url.split("/models/")[1].split("/v1")[0]
46
+ return url
47
+
48
+
49
  # ── Bootstrap: create settings + pool given user overrides ──────────────
50
  def build_pool(
51
  big_url: str = "",
 
58
  temperature: float = 0.3,
59
  max_tokens: int = 4096,
60
  strict_citations: bool = True,
61
+ ) -> tuple:
62
+ """Build an LLM pool + CachingPasalClient from user-provided overrides.
63
 
64
+ Uses HFLLMPool (InferenceClient) for HF endpoints,
65
+ LLMPool (OpenAI client) for custom endpoints.
66
  Falls through to env vars / HF defaults for anything left blank.
 
67
  """
68
  from datetime import date
69
 
 
71
  pasal_token = pasal_token or os.environ.get("PASAL_API_TOKEN", "")
72
 
73
  # Resolve BIG endpoint: user input → env var → HF default
74
+ resolved_big_url = big_url or os.environ.get("LLM_BIG_URL", "")
75
  resolved_big_key = big_key or os.environ.get("LLM_BIG_API_KEY", HF_TOKEN)
76
+ resolved_big_model = big_model or os.environ.get("LLM_BIG_MODEL", HF_BIG_MODEL)
77
 
78
  # Resolve SMALL endpoint: user input → env var → HF default
79
+ resolved_small_url = small_url or os.environ.get("LLM_SMALL_URL", "")
80
  resolved_small_key = small_key or os.environ.get("LLM_SMALL_API_KEY", HF_TOKEN)
81
+ resolved_small_model = small_model or os.environ.get("LLM_SMALL_MODEL", HF_SMALL_MODEL)
82
+
83
+ run_date = os.environ.get("LEGAWA_RUN_DATE", date.today().isoformat())
84
+
85
+ # Decide which backend to use
86
+ if not resolved_big_url or _is_hf_default(resolved_big_url):
87
+ # --- HF Inference Client (default, works reliably) ---
88
+ from hf_llm import HFLLMPool
89
+
90
+ big_mid = _model_id_from_url(resolved_big_url) if resolved_big_url else resolved_big_model
91
+ small_mid = _model_id_from_url(resolved_small_url) if resolved_small_url else resolved_small_model
92
+ pool = HFLLMPool(big_mid, small_mid, token=resolved_big_key)
93
+ pool.settings.run_date = run_date
94
+ pool.settings.corpus_watermark = os.environ.get("PASAL_CORPUS_WATERMARK", "")
95
+ pool.settings.strict_citations = strict_citations
96
+ else:
97
+ # --- OpenAI client (custom endpoint, e.g. llama.cpp) ---
98
+ big_cfg = LLMConfig(
99
+ base_url=resolved_big_url,
100
+ api_key=resolved_big_key,
101
+ model=resolved_big_model,
102
+ temperature=temperature,
103
+ max_tokens=max_tokens,
104
+ )
105
+ small_cfg = LLMConfig(
106
+ base_url=resolved_small_url,
107
+ api_key=resolved_small_key,
108
+ model=resolved_small_model,
109
+ temperature=temperature,
110
+ max_tokens=max_tokens,
111
+ )
112
+ override_settings = Settings(
113
+ pasal_token=pasal_token,
114
+ pasal_base_url=os.environ.get("PASAL_BASE_URL", "https://pasal.id/api/v1"),
115
+ big=big_cfg,
116
+ small=small_cfg,
117
+ run_date=run_date,
118
+ corpus_watermark=os.environ.get("PASAL_CORPUS_WATERMARK", ""),
119
+ strict_citations=strict_citations,
120
+ )
121
+ from legawa.llm import LLMPool
122
+ pool = LLMPool(override_settings)
123
+
124
+ raw = PasalClient(
125
+ _pasal_settings(pasal_token)
126
  )
127
+ pasal = CachingPasalClient(raw)
128
+ return pool, pasal
129
+
130
 
131
+ def _pasal_settings(pasal_token: str) -> Settings:
132
+ """Build a minimal Settings just for PasalClient."""
133
+ from legawa.config import LLMConfig
134
+ dummy = LLMConfig(base_url="", api_key="", model="", temperature=0.3, max_tokens=4096)
135
+ return Settings(
136
  pasal_token=pasal_token,
137
  pasal_base_url=os.environ.get("PASAL_BASE_URL", "https://pasal.id/api/v1"),
138
+ big=dummy, small=dummy,
139
+ run_date="", corpus_watermark="", strict_citations=False,
 
 
 
140
  )
141
 
 
 
 
142
  return pool, pasal
143
 
144
 
 
353
  )
354
 
355
  # ── Hidden state for connection config shared across tabs ──────
356
+ big_url = gr.Textbox(label="BIG LLM Model", value=HF_BIG_MODEL, visible=False)
357
  big_key = gr.Textbox(label="BIG LLM API Key", value=HF_TOKEN, visible=False)
358
+ small_url = gr.Textbox(label="SMALL LLM Model", value=HF_SMALL_MODEL, visible=False)
359
  small_key = gr.Textbox(label="SMALL LLM API Key", value=HF_TOKEN, visible=False)
360
  pasal_token = gr.Textbox(
361
  label="pasal.id Token",
 
502
  )
503
  with gr.Group():
504
  gr.Markdown("### 🧠 LLM BIG (sintesis, drafting)")
505
+ s_big_url = gr.Textbox(label="Model ID / URL", value=HF_BIG_MODEL)
506
  s_big_key = gr.Textbox(
507
  label="API Key",
508
  type="password",
 
514
  )
515
  with gr.Group():
516
  gr.Markdown("### 🧠 LLM SMALL (klasifikasi, ekstraksi)")
517
+ s_small_url = gr.Textbox(label="Model ID / URL", value=HF_SMALL_MODEL)
518
  s_small_key = gr.Textbox(
519
  label="API Key",
520
  type="password",
hf_llm.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hf_llm.py — Hugging Face Inference API wrapper for Legawa.
3
+
4
+ Implements the same interface as legawa.llm.LLM (chat, chat_with_tools)
5
+ but uses huggingface_hub's InferenceClient behind the scenes.
6
+
7
+ This is the DEFAULT backend for the HF Space (zero-config).
8
+ Users can switch to the OpenAI-based backend in Settings for custom endpoints.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Iterable
13
+
14
+ from huggingface_hub import InferenceClient
15
+
16
+
17
+ class HFLLM:
18
+ """Drop-in replacement for legawa.llm.LLM using HF Inference Client.
19
+
20
+ Matches the .chat() and .chat_with_tools() interface that the
21
+ agent code expects.
22
+ """
23
+
24
+ def __init__(self, model_id: str, token: str = "", **kwargs: Any):
25
+ self.model_id = model_id
26
+ self.client = InferenceClient(token=token or None)
27
+
28
+ def chat(
29
+ self,
30
+ messages: list[dict[str, Any]],
31
+ *,
32
+ temperature: float | None = None,
33
+ max_tokens: int | None = None,
34
+ think: bool = False,
35
+ ) -> str:
36
+ """Direct chat completion (no tools)."""
37
+ kwargs: dict[str, Any] = {"max_tokens": max_tokens or 4096}
38
+ if temperature is not None:
39
+ kwargs["temperature"] = temperature
40
+
41
+ resp = self.client.chat.completions.create(
42
+ model=self.model_id,
43
+ messages=messages,
44
+ **kwargs,
45
+ )
46
+ return resp.choices[0].message.content or ""
47
+
48
+ def chat_with_tools(
49
+ self,
50
+ messages: list[dict[str, Any]],
51
+ tools: Iterable[dict[str, Any]],
52
+ *,
53
+ temperature: float | None = None,
54
+ max_tokens: int | None = None,
55
+ think: bool = False,
56
+ ) -> Any:
57
+ """Single tool-calling round-trip.
58
+
59
+ Returns the raw choice.message object (must have .content, .tool_calls).
60
+ """
61
+ kwargs: dict[str, Any] = {"max_tokens": max_tokens or 4096}
62
+ if temperature is not None:
63
+ kwargs["temperature"] = temperature
64
+
65
+ resp = self.client.chat.completions.create(
66
+ model=self.model_id,
67
+ messages=messages,
68
+ tools=list(tools),
69
+ tool_choice="auto",
70
+ **kwargs,
71
+ )
72
+ return resp.choices[0].message
73
+
74
+
75
+ class HFLLMPool:
76
+ """Drop-in replacement for legawa.llm.LLMPool.
77
+
78
+ Wraps two HFLLM instances (big + small).
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ big_model_id: str,
84
+ small_model_id: str,
85
+ token: str = "",
86
+ **kwargs: Any,
87
+ ):
88
+ self.big = HFLLM(big_model_id, token=token)
89
+ self.small = HFLLM(small_model_id, token=token)
90
+ # Stub settings reference for code that accesses pool.settings
91
+ self.settings = _StubSettings()
92
+
93
+
94
+ class _StubSettings:
95
+ """Minimal stub so agent code that references pool.settings doesn't crash."""
96
+
97
+ run_date: str = ""
98
+ corpus_watermark: str = ""
99
+ strict_citations: bool = False
requirements.txt CHANGED
@@ -8,3 +8,4 @@ python-dotenv>=1.0.0,<2.0.0
8
  pypdf>=4.3.0,<6.0.0
9
  pydantic>=2.7.0,<3.0.0
10
  rich>=13.7.0
 
 
8
  pypdf>=4.3.0,<6.0.0
9
  pydantic>=2.7.0,<3.0.0
10
  rich>=13.7.0
11
+ huggingface-hub>=0.26.0