spagestic commited on
Commit
147e220
·
1 Parent(s): a960f0b

MiniCPM5 ZeroGPU migration

Browse files
.env.example CHANGED
@@ -1,6 +1,10 @@
1
  EXA_API_KEY=
2
  FIRECRAWL_API_KEY=
3
- BORDERLESS_MODEL_ID=Qwen/Qwen3.6-27B
 
 
 
 
4
  BORDERLESS_MAX_TOOL_ROUNDS=7
5
  BORDERLESS_TRACE_DIR=agent_traces
6
  # Set to 1 if you do not want local JSONL trace logs.
 
1
  EXA_API_KEY=
2
  FIRECRAWL_API_KEY=
3
+ HF_TOKEN=
4
+ BORDERLESS_MODEL_ID=openbmb/MiniCPM5-1B
5
+ BORDERLESS_INFERENCE_MODE=local
6
+ BORDERLESS_ENABLE_THINKING=1
7
+ BORDERLESS_GPU_DURATION=120
8
  BORDERLESS_MAX_TOOL_ROUNDS=7
9
  BORDERLESS_TRACE_DIR=agent_traces
10
  # Set to 1 if you do not want local JSONL trace logs.
README.md CHANGED
@@ -8,7 +8,7 @@ sdk_version: 6.16.0
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
- hardware: cpu-basic
12
  short_description: Agentic immigration research for global movers
13
  tags:
14
  - agents
@@ -17,12 +17,13 @@ tags:
17
  - travel
18
  - research
19
  - tool-use
20
- - qwen
 
21
  - langgraph
22
  - maplibre
23
  - geospatial
24
  models:
25
- - Qwen/Qwen3.6-27B
26
  datasets: []
27
  hf_oauth: true
28
  hf_oauth_scopes:
@@ -54,7 +55,7 @@ No forms to decode. No keyword guessing. Just a research session that meets you
54
 
55
  ## How it works
56
 
57
- Borderless uses a **[LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)** workflow tuned for **[Qwen/Qwen3.6-27B](https://huggingface.co/Qwen/Qwen3.6-27B)** (27B parameters within the hackathon's 32B cap):
58
 
59
  ```
60
  User profile → Planner → parallel Researchers (one per to-do) → Consolidator → final answer
@@ -78,7 +79,7 @@ Progress streams live in the chat: thinking steps, the research plan, tool calls
78
  | `crawl_web_site` | Multiple pages from an official immigration website section (Firecrawl) |
79
  | `update_globe` | Marks, highlights, and flies to countries on the MapLibre globe |
80
 
81
- Sign in with your Hugging Face account to run inference through the Inference API.
82
 
83
  ## Features
84
 
@@ -100,7 +101,7 @@ Sign in with your Hugging Face account to run inference through the Inference AP
100
 
101
  - **[Gradio Server](https://gradio.app)** — custom HTML/JS UI, OAuth, and streaming API endpoints
102
  - **[LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)** — planner / parallel researcher / consolidator workflow
103
- - **[Qwen3.6-27B](https://huggingface.co/Qwen/Qwen3.6-27B)** — planning and synthesis via Hugging Face Inference API (OpenAI-compatible router)
104
  - **[MapLibre GL JS](https://maplibre.org/)** — interactive 3D globe
105
  - **[Exa](https://exa.ai)** — neural web search for discovering immigration sources
106
  - **[Firecrawl](https://firecrawl.dev)** — scrape and crawl official web pages
@@ -141,7 +142,7 @@ data/
141
 
142
  | Constraint | Borderless |
143
  |------------|------------|
144
- | Model ≤ 32B | Qwen3.6-27B (27B) |
145
  | Gradio on HF Spaces | Yes — [live Space](https://huggingface.co/spaces/build-small-hackathon/borderless) |
146
  | Agentic | LangGraph multi-agent research with visible tool traces |
147
  | Sharing is Caring | JSONL tool traces can be sanitized and published — see `TRACE_SHARING.md` |
@@ -157,15 +158,19 @@ cp .env.example .env # then fill in API keys
157
  python app.py
158
  ```
159
 
160
- Set a Hugging Face token with Inference API access, or sign in through the app's OAuth flow when deployed.
161
 
162
  For web research tools, set API keys from [dashboard.exa.ai](https://dashboard.exa.ai/api-keys) and [firecrawl.dev](https://firecrawl.dev):
163
 
164
  | Variable | Purpose |
165
  |----------|---------|
 
166
  | `EXA_API_KEY` | `search_immigration_info` |
167
  | `FIRECRAWL_API_KEY` | `scrape_web_page`, `crawl_web_site` |
168
- | `BORDERLESS_MODEL_ID` | Model override (default `Qwen/Qwen3.6-27B`) |
 
 
 
169
  | `BORDERLESS_AGENT_MODE` | `graph` (default) or `legacy` for the single-agent loop |
170
  | `BORDERLESS_TRACE_DIR` | JSONL trace output directory |
171
  | `BORDERLESS_DISABLE_TRACE_LOGS` | Set to `1` to disable local trace logs |
@@ -174,4 +179,4 @@ On Hugging Face Spaces, add API keys as **Space secrets** (Settings → Secrets)
174
 
175
  ## License
176
 
177
- Apache-2.0 (model: [Qwen/Qwen3.6-27B](https://huggingface.co/Qwen/Qwen3.6-27B))
 
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
+ hardware: zero-gpu
12
  short_description: Agentic immigration research for global movers
13
  tags:
14
  - agents
 
17
  - travel
18
  - research
19
  - tool-use
20
+ - minicpm
21
+ - openbmb
22
  - langgraph
23
  - maplibre
24
  - geospatial
25
  models:
26
+ - openbmb/MiniCPM5-1B
27
  datasets: []
28
  hf_oauth: true
29
  hf_oauth_scopes:
 
55
 
56
  ## How it works
57
 
58
+ Borderless uses a **[LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)** workflow powered by **[openbmb/MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B)** running locally on **ZeroGPU** (1B parameters):
59
 
60
  ```
61
  User profile → Planner → parallel Researchers (one per to-do) → Consolidator → final answer
 
79
  | `crawl_web_site` | Multiple pages from an official immigration website section (Firecrawl) |
80
  | `update_globe` | Marks, highlights, and flies to countries on the MapLibre globe |
81
 
82
+ Sign in with your Hugging Face account for the app OAuth flow. LLM inference runs locally on ZeroGPU when `BORDERLESS_INFERENCE_MODE=local` (default); set `HF_TOKEN` as a Space secret for model download.
83
 
84
  ## Features
85
 
 
101
 
102
  - **[Gradio Server](https://gradio.app)** — custom HTML/JS UI, OAuth, and streaming API endpoints
103
  - **[LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)** — planner / parallel researcher / consolidator workflow
104
+ - **[MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B)** — local planning and synthesis via transformers on ZeroGPU
105
  - **[MapLibre GL JS](https://maplibre.org/)** — interactive 3D globe
106
  - **[Exa](https://exa.ai)** — neural web search for discovering immigration sources
107
  - **[Firecrawl](https://firecrawl.dev)** — scrape and crawl official web pages
 
142
 
143
  | Constraint | Borderless |
144
  |------------|------------|
145
+ | Model ≤ 32B | MiniCPM5-1B (1B) |
146
  | Gradio on HF Spaces | Yes — [live Space](https://huggingface.co/spaces/build-small-hackathon/borderless) |
147
  | Agentic | LangGraph multi-agent research with visible tool traces |
148
  | Sharing is Caring | JSONL tool traces can be sanitized and published — see `TRACE_SHARING.md` |
 
158
  python app.py
159
  ```
160
 
161
+ Set `HF_TOKEN` in `.env` for model download. For router-based inference (no local GPU), set `BORDERLESS_INFERENCE_MODE=router` and sign in through OAuth.
162
 
163
  For web research tools, set API keys from [dashboard.exa.ai](https://dashboard.exa.ai/api-keys) and [firecrawl.dev](https://firecrawl.dev):
164
 
165
  | Variable | Purpose |
166
  |----------|---------|
167
+ | `HF_TOKEN` | Hugging Face Hub login + MiniCPM weight download |
168
  | `EXA_API_KEY` | `search_immigration_info` |
169
  | `FIRECRAWL_API_KEY` | `scrape_web_page`, `crawl_web_site` |
170
+ | `BORDERLESS_MODEL_ID` | Model override (default `openbmb/MiniCPM5-1B`) |
171
+ | `BORDERLESS_INFERENCE_MODE` | `local` (default, ZeroGPU) or `router` (HF Inference API) |
172
+ | `BORDERLESS_ENABLE_THINKING` | `1` (default) enables MiniCPM thinking mode in chat template |
173
+ | `BORDERLESS_GPU_DURATION` | ZeroGPU quota reservation per chat run (default `120`) |
174
  | `BORDERLESS_AGENT_MODE` | `graph` (default) or `legacy` for the single-agent loop |
175
  | `BORDERLESS_TRACE_DIR` | JSONL trace output directory |
176
  | `BORDERLESS_DISABLE_TRACE_LOGS` | Set to `1` to disable local trace logs |
 
179
 
180
  ## License
181
 
182
+ Apache-2.0 (model: [openbmb/MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B))
requirements.txt CHANGED
@@ -6,6 +6,7 @@ openai==2.41.0
6
  langchain-core==1.4.6
7
  langchain-openai==1.3.0
8
  langgraph==1.2.4
 
9
  mcp==1.27.2
10
 
11
  # Tools / APIs
 
6
  langchain-core==1.4.6
7
  langchain-openai==1.3.0
8
  langgraph==1.2.4
9
+ transformers==4.57.3
10
  mcp==1.27.2
11
 
12
  # Tools / APIs
tests/test_llm_wrapper.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_llm_wrapper.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import unittest
6
+ from unittest.mock import patch
7
+
8
+ os.environ.setdefault("BORDERLESS_INFERENCE_MODE", "router")
9
+ os.environ.setdefault("BORDERLESS_PRELOAD_MODEL", "0")
10
+
11
+ from langchain_openai import ChatOpenAI
12
+
13
+ from ui.agent.graph.llm import MiniCPMChatModel, build_llm
14
+
15
+
16
+ class BuildLlmTests(unittest.TestCase):
17
+ @patch("ui.agent.graph.llm.local_inference_enabled", return_value=True)
18
+ def test_local_mode_returns_minicpm_wrapper(self, _mock_local: unittest.mock.Mock) -> None:
19
+ llm = build_llm({"configurable": {"max_tokens": 512, "temperature": 0.2, "top_p": 0.8}})
20
+ self.assertIsInstance(llm, MiniCPMChatModel)
21
+ self.assertEqual(llm.max_tokens, 512)
22
+
23
+ @patch("ui.agent.graph.llm.local_inference_enabled", return_value=False)
24
+ def test_router_mode_returns_chat_openai(self, _mock_local: unittest.mock.Mock) -> None:
25
+ llm = build_llm(
26
+ {
27
+ "configurable": {
28
+ "hf_token": "test-token",
29
+ "max_tokens": 512,
30
+ "temperature": 0.2,
31
+ "top_p": 0.8,
32
+ }
33
+ }
34
+ )
35
+ self.assertIsInstance(llm, ChatOpenAI)
36
+
37
+ @patch("ui.agent.graph.llm.local_inference_enabled", return_value=True)
38
+ def test_minicpm_bind_tools_preserves_generation_params(
39
+ self,
40
+ _mock_local: unittest.mock.Mock,
41
+ ) -> None:
42
+ llm = MiniCPMChatModel(max_tokens=128, temperature=0.1, top_p=0.5)
43
+ bound = llm.bind_tools(
44
+ [{"type": "function", "function": {"name": "search_immigration_info"}}]
45
+ )
46
+ self.assertEqual(bound.max_tokens, 128)
47
+ self.assertEqual(len(bound.tools), 1)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()
tests/test_minicpm_messages.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_minicpm_messages.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import unittest
7
+
8
+ os.environ.setdefault("BORDERLESS_INFERENCE_MODE", "router")
9
+ os.environ.setdefault("BORDERLESS_PRELOAD_MODEL", "0")
10
+
11
+ from langchain_core.messages import AIMessage, ToolMessage
12
+
13
+ from ui.agent.minicpm.messages import (
14
+ append_tool_instructions,
15
+ normalize_messages,
16
+ )
17
+
18
+
19
+ class NormalizeMessagesTests(unittest.TestCase):
20
+ def test_dict_messages(self) -> None:
21
+ messages = [
22
+ {"role": "system", "content": "You are helpful."},
23
+ {"role": "user", "content": "Hello"},
24
+ ]
25
+ normalized = normalize_messages(messages)
26
+ self.assertEqual(normalized[0]["role"], "system")
27
+ self.assertEqual(normalized[1]["content"], "Hello")
28
+
29
+ def test_tool_message_becomes_user_context(self) -> None:
30
+ messages = [
31
+ ToolMessage(content='{"results": []}', tool_call_id="call-1"),
32
+ ]
33
+ normalized = normalize_messages(messages)
34
+ self.assertEqual(normalized[0]["role"], "user")
35
+ self.assertIn("Tool result", normalized[0]["content"])
36
+
37
+ def test_ai_message(self) -> None:
38
+ messages = [AIMessage(content="Done.")]
39
+ normalized = normalize_messages(messages)
40
+ self.assertEqual(normalized[0]["role"], "assistant")
41
+ self.assertEqual(normalized[0]["content"], "Done.")
42
+
43
+
44
+ class ToolInstructionTests(unittest.TestCase):
45
+ def test_appends_tool_block_to_system(self) -> None:
46
+ tools = [
47
+ {
48
+ "type": "function",
49
+ "function": {
50
+ "name": "search_immigration_info",
51
+ "description": "Search immigration sources",
52
+ "parameters": {
53
+ "type": "object",
54
+ "properties": {"query": {"type": "string"}},
55
+ },
56
+ },
57
+ }
58
+ ]
59
+ messages = [
60
+ {"role": "system", "content": "Base prompt"},
61
+ {"role": "user", "content": "Research Canada"},
62
+ ]
63
+ updated = append_tool_instructions(messages, tools)
64
+ self.assertIn("search_immigration_info", updated[0]["content"])
65
+ self.assertIn(json.dumps(tools[0]["function"]["parameters"]), updated[0]["content"])
66
+
67
+
68
+ if __name__ == "__main__":
69
+ unittest.main()
tests/test_research_findings.py CHANGED
@@ -2,9 +2,13 @@
2
  from __future__ import annotations
3
 
4
  import json
 
5
  import unittest
6
  from unittest.mock import patch
7
 
 
 
 
8
  from langchain_core.messages import AIMessage, ToolMessage
9
 
10
  from ui.agent.graph.nodes.helpers import extract_assistant_text, research_tool_calls
 
2
  from __future__ import annotations
3
 
4
  import json
5
+ import os
6
  import unittest
7
  from unittest.mock import patch
8
 
9
+ os.environ.setdefault("BORDERLESS_INFERENCE_MODE", "router")
10
+ os.environ.setdefault("BORDERLESS_PRELOAD_MODEL", "0")
11
+
12
  from langchain_core.messages import AIMessage, ToolMessage
13
 
14
  from ui.agent.graph.nodes.helpers import extract_assistant_text, research_tool_calls
ui/agent/config.py CHANGED
@@ -6,8 +6,23 @@ import os
6
  from .tool_schemas import TOOL_SCHEMAS
7
  from .tool_schemas import think as THINK_SCHEMA
8
 
9
- MODEL_ID = os.environ.get("BORDERLESS_MODEL_ID", "Qwen/Qwen3.6-27B")
 
 
 
 
 
 
 
10
  MAX_TOOL_ROUNDS = int(os.environ.get("BORDERLESS_MAX_TOOL_ROUNDS", "7"))
11
 
12
  TOOLS = TOOL_SCHEMAS
13
  THINK_TOOLS = [THINK_SCHEMA]
 
 
 
 
 
 
 
 
 
6
  from .tool_schemas import TOOL_SCHEMAS
7
  from .tool_schemas import think as THINK_SCHEMA
8
 
9
+ MODEL_ID = os.environ.get("BORDERLESS_MODEL_ID", "openbmb/MiniCPM5-1B")
10
+ INFERENCE_MODE = os.environ.get("BORDERLESS_INFERENCE_MODE", "local").strip().lower()
11
+ ENABLE_THINKING = os.environ.get("BORDERLESS_ENABLE_THINKING", "1").strip().lower() in {
12
+ "1",
13
+ "true",
14
+ "yes",
15
+ }
16
+ GPU_DURATION = int(os.environ.get("BORDERLESS_GPU_DURATION", "120"))
17
  MAX_TOOL_ROUNDS = int(os.environ.get("BORDERLESS_MAX_TOOL_ROUNDS", "7"))
18
 
19
  TOOLS = TOOL_SCHEMAS
20
  THINK_TOOLS = [THINK_SCHEMA]
21
+
22
+
23
+ def local_inference_enabled() -> bool:
24
+ return INFERENCE_MODE == "local"
25
+
26
+
27
+ def hub_token_available() -> bool:
28
+ return bool(os.environ.get("HF_TOKEN", "").strip())
ui/agent/graph/llm.py CHANGED
@@ -1,26 +1,68 @@
1
  # ui/agent/graph/llm.py
2
  from __future__ import annotations
3
 
 
4
  from typing import Any
5
 
 
6
  from langchain_core.runnables import RunnableConfig
7
  from langchain_openai import ChatOpenAI
8
 
9
- from ..config import MODEL_ID
10
 
11
  HF_ROUTER_BASE_URL = "https://router.huggingface.co/v1"
12
 
13
 
14
- def build_llm(config: RunnableConfig, **overrides: Any) -> ChatOpenAI:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  """Build a chat model from the per-request configurable values."""
16
  configurable = config.get("configurable", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  params: dict[str, Any] = {
18
  "model": MODEL_ID,
19
- "api_key": configurable["hf_token"],
20
  "base_url": HF_ROUTER_BASE_URL,
21
- "max_tokens": configurable.get("max_tokens", 1800),
22
- "temperature": configurable.get("temperature", 0.35),
23
- "top_p": configurable.get("top_p", 0.9),
24
  }
25
  params.update(overrides)
26
  return ChatOpenAI(**params)
 
1
  # ui/agent/graph/llm.py
2
  from __future__ import annotations
3
 
4
+ from dataclasses import dataclass, field
5
  from typing import Any
6
 
7
+ from langchain_core.messages import AIMessage
8
  from langchain_core.runnables import RunnableConfig
9
  from langchain_openai import ChatOpenAI
10
 
11
+ from ..config import MODEL_ID, local_inference_enabled
12
 
13
  HF_ROUTER_BASE_URL = "https://router.huggingface.co/v1"
14
 
15
 
16
+ @dataclass
17
+ class MiniCPMChatModel:
18
+ max_tokens: int = 1800
19
+ temperature: float = 0.35
20
+ top_p: float = 0.9
21
+ tools: list[dict[str, Any]] = field(default_factory=list)
22
+
23
+ def bind_tools(self, tools: list[dict[str, Any]]) -> MiniCPMChatModel:
24
+ return MiniCPMChatModel(
25
+ max_tokens=self.max_tokens,
26
+ temperature=self.temperature,
27
+ top_p=self.top_p,
28
+ tools=list(tools),
29
+ )
30
+
31
+ def invoke(self, messages: list[Any]) -> AIMessage:
32
+ from ..minicpm.model import chat_complete
33
+
34
+ return chat_complete(
35
+ messages,
36
+ tools=self.tools or None,
37
+ max_tokens=self.max_tokens,
38
+ temperature=self.temperature,
39
+ top_p=self.top_p,
40
+ )
41
+
42
+
43
+ def build_llm(config: RunnableConfig, **overrides: Any) -> MiniCPMChatModel | ChatOpenAI:
44
  """Build a chat model from the per-request configurable values."""
45
  configurable = config.get("configurable", {})
46
+ max_tokens = int(overrides.pop("max_tokens", configurable.get("max_tokens", 1800)))
47
+ temperature = float(
48
+ overrides.pop("temperature", configurable.get("temperature", 0.35))
49
+ )
50
+ top_p = float(overrides.pop("top_p", configurable.get("top_p", 0.9)))
51
+
52
+ if local_inference_enabled():
53
+ return MiniCPMChatModel(
54
+ max_tokens=max_tokens,
55
+ temperature=temperature,
56
+ top_p=top_p,
57
+ )
58
+
59
  params: dict[str, Any] = {
60
  "model": MODEL_ID,
61
+ "api_key": configurable.get("hf_token") or "",
62
  "base_url": HF_ROUTER_BASE_URL,
63
+ "max_tokens": max_tokens,
64
+ "temperature": temperature,
65
+ "top_p": top_p,
66
  }
67
  params.update(overrides)
68
  return ChatOpenAI(**params)
ui/agent/graph/nodes/consolidator.py CHANGED
@@ -10,12 +10,10 @@ from ..llm import build_llm
10
  from ..state import AgentState
11
  from ...synthesis import build_structured_final_answer
12
  from .config import CONSOLIDATOR_MAX_TOKENS, FINDING_SUMMARY_LIMIT
 
13
  from .prompts import CONSOLIDATOR_SYSTEM_PROMPT
14
 
15
 
16
- from .helpers import format_todo_label
17
-
18
-
19
  def consolidator_node(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
20
  findings = sorted(state.get("findings", []), key=lambda item: item["todo_id"])
21
  todo_by_id = {todo["id"]: todo for todo in state.get("todos") or []}
@@ -44,7 +42,7 @@ def consolidator_node(state: AgentState, config: RunnableConfig) -> dict[str, An
44
  },
45
  ]
46
  response = llm.invoke(messages)
47
- answer = str(response.content or "").strip()
48
  if not answer:
49
  answer = build_structured_final_answer(
50
  profile_summary=str(state.get("profile_summary") or "").strip(),
 
10
  from ..state import AgentState
11
  from ...synthesis import build_structured_final_answer
12
  from .config import CONSOLIDATOR_MAX_TOKENS, FINDING_SUMMARY_LIMIT
13
+ from .helpers import extract_assistant_text, format_todo_label
14
  from .prompts import CONSOLIDATOR_SYSTEM_PROMPT
15
 
16
 
 
 
 
17
  def consolidator_node(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
18
  findings = sorted(state.get("findings", []), key=lambda item: item["todo_id"])
19
  todo_by_id = {todo["id"]: todo for todo in state.get("todos") or []}
 
42
  },
43
  ]
44
  response = llm.invoke(messages)
45
+ answer = extract_assistant_text(response)
46
  if not answer:
47
  answer = build_structured_final_answer(
48
  profile_summary=str(state.get("profile_summary") or "").strip(),
ui/agent/graph/nodes/planner.py CHANGED
@@ -11,6 +11,7 @@ from ..llm import build_llm
11
  from ..state import AgentState, CandidateCountry, TodoItem
12
  from .config import MAX_TODOS, PLANNER_MAX_TOKENS, PLANNER_TEMPERATURE
13
  from .helpers import (
 
14
  extract_json,
15
  heuristic_candidate_countries,
16
  normalize_plan,
@@ -69,7 +70,7 @@ def planner_node(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
69
  raw_plan: dict[str, Any] | None = None
70
  for _ in range(2):
71
  response = llm.invoke(messages)
72
- raw_plan = extract_json(str(response.content or ""))
73
  if raw_plan and raw_plan.get("todos"):
74
  break
75
  raw_plan = None
 
11
  from ..state import AgentState, CandidateCountry, TodoItem
12
  from .config import MAX_TODOS, PLANNER_MAX_TOKENS, PLANNER_TEMPERATURE
13
  from .helpers import (
14
+ extract_assistant_text,
15
  extract_json,
16
  heuristic_candidate_countries,
17
  normalize_plan,
 
70
  raw_plan: dict[str, Any] | None = None
71
  for _ in range(2):
72
  response = llm.invoke(messages)
73
+ raw_plan = extract_json(extract_assistant_text(response))
74
  if raw_plan and raw_plan.get("todos"):
75
  break
76
  raw_plan = None
ui/agent/graph/respond.py CHANGED
@@ -11,6 +11,7 @@ from ui.globe_commands import apply_update_globe
11
  from ui.globe_details import attach_finding_to_globe, attach_research_progress_to_globe
12
 
13
  from ..messages import history_to_api_messages, multimodal_input_to_api_content
 
14
  from ..respond import (
15
  AUTH_REQUIRED_MESSAGE,
16
  SESSION_EXPIRED_MESSAGE,
@@ -243,11 +244,11 @@ def respond_with_graph(
243
  hf_token: gr.OAuthToken | None,
244
  ):
245
  """LangGraph-backed drop-in replacement for ui.agent.respond.respond."""
246
- if hf_token is None:
247
  yield from yield_response([], AUTH_REQUIRED_MESSAGE, globe_state)
248
  return
249
 
250
- if hf_token.expires_at <= time.time():
251
  yield from yield_response([], SESSION_EXPIRED_MESSAGE, globe_state)
252
  return
253
 
@@ -267,7 +268,7 @@ def respond_with_graph(
267
 
268
  config = {
269
  "configurable": {
270
- "hf_token": hf_token.token,
271
  "max_tokens": max_tokens,
272
  "temperature": temperature,
273
  "top_p": top_p,
 
11
  from ui.globe_details import attach_finding_to_globe, attach_research_progress_to_globe
12
 
13
  from ..messages import history_to_api_messages, multimodal_input_to_api_content
14
+ from ..config import hub_token_available, local_inference_enabled
15
  from ..respond import (
16
  AUTH_REQUIRED_MESSAGE,
17
  SESSION_EXPIRED_MESSAGE,
 
244
  hf_token: gr.OAuthToken | None,
245
  ):
246
  """LangGraph-backed drop-in replacement for ui.agent.respond.respond."""
247
+ if hf_token is None and not (local_inference_enabled() and hub_token_available()):
248
  yield from yield_response([], AUTH_REQUIRED_MESSAGE, globe_state)
249
  return
250
 
251
+ if hf_token is not None and hf_token.expires_at <= time.time():
252
  yield from yield_response([], SESSION_EXPIRED_MESSAGE, globe_state)
253
  return
254
 
 
268
 
269
  config = {
270
  "configurable": {
271
+ "hf_token": hf_token.token if hf_token is not None else "",
272
  "max_tokens": max_tokens,
273
  "temperature": temperature,
274
  "top_p": top_p,
ui/agent/minicpm/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # ui/agent/minicpm/__init__.py
2
+ from __future__ import annotations
3
+
4
+ __all__: list[str] = []
ui/agent/minicpm/messages.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/agent/minicpm/messages.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any
6
+
7
+ from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
8
+
9
+
10
+ def _flatten_content(content: Any) -> str:
11
+ if content is None:
12
+ return ""
13
+ if isinstance(content, str):
14
+ return content
15
+ if isinstance(content, list):
16
+ parts: list[str] = []
17
+ for block in content:
18
+ if isinstance(block, str):
19
+ parts.append(block)
20
+ continue
21
+ if isinstance(block, dict):
22
+ if block.get("type") == "text":
23
+ parts.append(str(block.get("text") or ""))
24
+ elif "text" in block:
25
+ parts.append(str(block["text"]))
26
+ return "\n".join(part for part in parts if part)
27
+ return str(content)
28
+
29
+
30
+ def normalize_messages(messages: list[Any]) -> list[dict[str, str]]:
31
+ """Convert LangChain or OpenAI-style messages to MiniCPM chat messages."""
32
+ normalized: list[dict[str, str]] = []
33
+ for message in messages:
34
+ if isinstance(message, dict):
35
+ role = str(message.get("role") or "user")
36
+ content = _flatten_content(message.get("content"))
37
+ if content:
38
+ normalized.append({"role": role, "content": content})
39
+ continue
40
+
41
+ if isinstance(message, BaseMessage):
42
+ if isinstance(message, ToolMessage):
43
+ tool_id = getattr(message, "tool_call_id", "") or "tool"
44
+ content = _flatten_content(message.content)
45
+ normalized.append(
46
+ {
47
+ "role": "user",
48
+ "content": f"Tool result ({tool_id}):\n{content}",
49
+ }
50
+ )
51
+ continue
52
+
53
+ role = getattr(message, "type", "user")
54
+ if role == "human":
55
+ role = "user"
56
+ elif role == "ai":
57
+ role = "assistant"
58
+ content = _flatten_content(message.content)
59
+ if content:
60
+ normalized.append({"role": role, "content": content})
61
+ continue
62
+
63
+ normalized.append({"role": "user", "content": str(message)})
64
+ return normalized
65
+
66
+
67
+ def append_tool_instructions(
68
+ messages: list[dict[str, str]],
69
+ tools: list[dict[str, Any]],
70
+ ) -> list[dict[str, str]]:
71
+ if not tools:
72
+ return messages
73
+
74
+ lines = [
75
+ "You may call tools by emitting a single line in this exact format:",
76
+ "tool_name{\"arg\": \"value\"}",
77
+ "",
78
+ "Available tools:",
79
+ ]
80
+ for tool in tools:
81
+ function = tool.get("function") or {}
82
+ name = str(function.get("name") or "")
83
+ description = str(function.get("description") or "").strip()
84
+ parameters = function.get("parameters") or {}
85
+ lines.append(f"- {name}: {description}")
86
+ lines.append(f" parameters schema: {json.dumps(parameters, ensure_ascii=True)}")
87
+
88
+ tool_block = "\n".join(lines)
89
+ if not messages:
90
+ return [{"role": "system", "content": tool_block}]
91
+
92
+ updated = list(messages)
93
+ if updated[0]["role"] == "system":
94
+ updated[0] = {
95
+ "role": "system",
96
+ "content": f"{updated[0]['content']}\n\n{tool_block}",
97
+ }
98
+ else:
99
+ updated.insert(0, {"role": "system", "content": tool_block})
100
+ return updated
ui/agent/minicpm/model.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/agent/minicpm/model.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import threading
7
+ from typing import Any
8
+
9
+ import torch
10
+ from huggingface_hub import login
11
+ from langchain_core.messages import AIMessage
12
+ from transformers import AutoModelForCausalLM, AutoTokenizer
13
+
14
+ from ..config import ENABLE_THINKING, MODEL_ID
15
+ from .messages import append_tool_instructions, normalize_messages
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _GENERATE_LOCK = threading.Lock()
20
+ _MODEL: AutoModelForCausalLM | None = None
21
+ _TOKENIZER: AutoTokenizer | None = None
22
+ _DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
23
+
24
+
25
+ def _hub_login() -> None:
26
+ hf_token = os.environ.get("HF_TOKEN")
27
+ if hf_token:
28
+ login(token=hf_token)
29
+ logger.info("Logged in to Hugging Face Hub for MiniCPM weights")
30
+ else:
31
+ logger.warning("HF_TOKEN not set — gated MiniCPM weights may be inaccessible")
32
+
33
+
34
+ def _load_model() -> tuple[AutoTokenizer, AutoModelForCausalLM]:
35
+ global _MODEL, _TOKENIZER
36
+ if _MODEL is not None and _TOKENIZER is not None:
37
+ return _TOKENIZER, _MODEL
38
+
39
+ _hub_login()
40
+ logger.info("Loading MiniCPM model %s on %s", MODEL_ID, _DEVICE)
41
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
42
+ model = AutoModelForCausalLM.from_pretrained(
43
+ MODEL_ID,
44
+ torch_dtype=torch.bfloat16,
45
+ trust_remote_code=True,
46
+ ).to(_DEVICE)
47
+ _TOKENIZER = tokenizer
48
+ _MODEL = model
49
+ return tokenizer, model
50
+
51
+
52
+ def _apply_chat_template(
53
+ tokenizer: AutoTokenizer,
54
+ messages: list[dict[str, str]],
55
+ *,
56
+ enable_thinking: bool,
57
+ ) -> str:
58
+ kwargs: dict[str, Any] = {
59
+ "tokenize": False,
60
+ "add_generation_prompt": True,
61
+ }
62
+ try:
63
+ return tokenizer.apply_chat_template(
64
+ messages,
65
+ enable_thinking=enable_thinking,
66
+ **kwargs,
67
+ )
68
+ except TypeError:
69
+ return tokenizer.apply_chat_template(messages, **kwargs)
70
+
71
+
72
+ def _split_think_output(text: str) -> tuple[str, str]:
73
+ open_tag = "<" + "think" + ">"
74
+ close_tag = "</" + "think" + ">"
75
+ start = text.find(open_tag)
76
+ end = text.find(close_tag)
77
+ if start != -1 and end != -1 and end > start:
78
+ reasoning = text[start + len(open_tag) : end].strip()
79
+ content = (text[:start] + text[end + len(close_tag) :]).strip()
80
+ return content, reasoning
81
+ return text.strip(), ""
82
+
83
+
84
+ def chat_complete(
85
+ messages: list[Any],
86
+ *,
87
+ tools: list[dict[str, Any]] | None = None,
88
+ max_tokens: int = 1800,
89
+ temperature: float = 0.35,
90
+ top_p: float = 0.9,
91
+ enable_thinking: bool | None = None,
92
+ ) -> AIMessage:
93
+ """Run one MiniCPM chat turn and return a LangChain AIMessage."""
94
+ tokenizer, model = _load_model()
95
+ normalized = normalize_messages(messages)
96
+ if tools:
97
+ normalized = append_tool_instructions(normalized, tools)
98
+
99
+ thinking = ENABLE_THINKING if enable_thinking is None else enable_thinking
100
+ prompt_text = _apply_chat_template(tokenizer, normalized, enable_thinking=thinking)
101
+ model_inputs = tokenizer([prompt_text], return_tensors="pt").to(_DEVICE)
102
+
103
+ gen_kwargs: dict[str, Any] = {
104
+ **model_inputs,
105
+ "max_new_tokens": max_tokens,
106
+ }
107
+ if temperature > 0:
108
+ gen_kwargs.update(
109
+ temperature=temperature,
110
+ top_p=top_p,
111
+ do_sample=True,
112
+ )
113
+ else:
114
+ gen_kwargs["do_sample"] = False
115
+
116
+ with _GENERATE_LOCK:
117
+ output_ids = model.generate(**gen_kwargs)
118
+
119
+ generated = output_ids[0][model_inputs["input_ids"].shape[1] :]
120
+ raw_text = tokenizer.decode(generated, skip_special_tokens=False)
121
+ content, reasoning = _split_think_output(raw_text)
122
+
123
+ additional_kwargs: dict[str, Any] = {}
124
+ if reasoning:
125
+ additional_kwargs["reasoning_content"] = reasoning
126
+
127
+ return AIMessage(content=content or raw_text, additional_kwargs=additional_kwargs)
128
+
129
+
130
+ def _maybe_preload() -> None:
131
+ if os.environ.get("BORDERLESS_INFERENCE_MODE", "local") != "local":
132
+ return
133
+ if os.environ.get("BORDERLESS_PRELOAD_MODEL", "1") != "1":
134
+ return
135
+ try:
136
+ _load_model()
137
+ except Exception as exc:
138
+ logger.warning("MiniCPM eager preload skipped: %s", exc)
139
+
140
+
141
+ _maybe_preload()
ui/server_api.py CHANGED
@@ -2,11 +2,14 @@
2
  from __future__ import annotations
3
 
4
  import os
 
5
  from typing import Any
6
 
7
  import gradio as gr
 
8
  from gradio import ChatMessage
9
 
 
10
  from ui.agent.graph import respond_with_graph
11
  from ui.agent.respond import respond as respond_legacy
12
  from ui.agent.system_prompt import BORDERLESS_SYSTEM_PROMPT
@@ -127,26 +130,51 @@ def _merge_chat_history(
127
  return updated_history
128
 
129
 
130
- def stream_chat(
131
  message: str,
132
  history: list[dict[str, Any]],
133
- globe_state: dict[str, Any] | None,
134
  hf_token: gr.OAuthToken | None,
135
- ):
136
- state = globe_state if globe_state else empty_globe_state()
137
- ui_messages: list[ChatMessage] = []
138
- assistant_text = ""
139
-
140
- for chunk in _respond_fn()(
141
  message,
142
  history,
143
  BORDERLESS_SYSTEM_PROMPT,
144
  DEFAULT_MAX_TOKENS,
145
  DEFAULT_TEMPERATURE,
146
  DEFAULT_TOP_P,
147
- state,
148
  hf_token,
149
- ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  payload, state = chunk
151
  if isinstance(payload, list):
152
  ui_messages = payload
 
2
  from __future__ import annotations
3
 
4
  import os
5
+ from collections.abc import Iterator
6
  from typing import Any
7
 
8
  import gradio as gr
9
+ import spaces
10
  from gradio import ChatMessage
11
 
12
+ from ui.agent.config import GPU_DURATION, local_inference_enabled
13
  from ui.agent.graph import respond_with_graph
14
  from ui.agent.respond import respond as respond_legacy
15
  from ui.agent.system_prompt import BORDERLESS_SYSTEM_PROMPT
 
130
  return updated_history
131
 
132
 
133
+ def _stream_agent_response(
134
  message: str,
135
  history: list[dict[str, Any]],
136
+ globe_state: dict[str, Any],
137
  hf_token: gr.OAuthToken | None,
138
+ ) -> Iterator[tuple[Any, dict[str, Any]]]:
139
+ yield from _respond_fn()(
 
 
 
 
140
  message,
141
  history,
142
  BORDERLESS_SYSTEM_PROMPT,
143
  DEFAULT_MAX_TOKENS,
144
  DEFAULT_TEMPERATURE,
145
  DEFAULT_TOP_P,
146
+ globe_state,
147
  hf_token,
148
+ )
149
+
150
+
151
+ @spaces.GPU(duration=GPU_DURATION)
152
+ def _run_agent_graph_gpu(
153
+ message: str,
154
+ history: list[dict[str, Any]],
155
+ globe_state: dict[str, Any],
156
+ hf_token: gr.OAuthToken | None,
157
+ ) -> Iterator[tuple[Any, dict[str, Any]]]:
158
+ yield from _stream_agent_response(message, history, globe_state, hf_token)
159
+
160
+
161
+ def stream_chat(
162
+ message: str,
163
+ history: list[dict[str, Any]],
164
+ globe_state: dict[str, Any] | None,
165
+ hf_token: gr.OAuthToken | None,
166
+ ):
167
+ state = globe_state if globe_state else empty_globe_state()
168
+ ui_messages: list[ChatMessage] = []
169
+ assistant_text = ""
170
+
171
+ agent_stream = (
172
+ _run_agent_graph_gpu(message, history, state, hf_token)
173
+ if local_inference_enabled() and AGENT_MODE != "legacy"
174
+ else _stream_agent_response(message, history, state, hf_token)
175
+ )
176
+
177
+ for chunk in agent_stream:
178
  payload, state = chunk
179
  if isinstance(payload, list):
180
  ui_messages = payload