Spaces:
Sleeping
Sleeping
File size: 8,199 Bytes
b2931f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | """Local / edge backend β Llama 3.2 3B via Ollama's OpenAI-compatible API.
This is the third provider behind the seam (see [[gemini]], [[claude]]). It maps
Ollama's responses onto the same `SynthesisResult`/`ToolLoopResult`, so no node,
tool, or graph code learns a local model is answering β `llm_provider="local"`
is the only switch.
Why the OpenAI client (not Ollama's native API): Ollama exposes an
OpenAI-compatible endpoint on :11434/v1, so the *same* code targets vLLM,
llama.cpp, or LM Studio by changing `local_base_url`. That portability is the
point of the edge story β the seam isn't Ollama-specific, it's "any
OpenAI-compatible local server."
The edge reality (the finding this variant exists to produce): a 3B model
retrieves + synthesizes grounded answers fine, but its tool-calling is weak β it
mis-forms or skips function calls that Claude handles reliably. So `tool_loop`
honors `settings.local_use_tools`: True runs the real agentic loop (and we report
how often it misfires); False degrades to synthesis-only over the provided
context, which is what small local models can actually do dependably.
No prompt caching here β local inference has no per-token cost or cache tier, so
the cache fields on SynthesisResult stay 0 (same as Gemini, see [[base]]).
"""
from __future__ import annotations
import json
from functools import lru_cache
from openai import OpenAI
from finrag.config import settings
from finrag.llm.base import (
MAX_TOKENS,
SYSTEM_PROMPT,
SynthesisResult,
ToolCall,
ToolLoopResult,
build_user_message,
empty_result,
json_safe,
)
from finrag.retrieval.vector import RetrievedChunk
@lru_cache(maxsize=1)
def get_local_client() -> OpenAI:
"""OpenAI client pointed at the local Ollama server. The api_key is a
required-but-ignored placeholder (Ollama doesn't auth). A clear error if the
daemon isn't up mirrors the missing-key errors on the cloud backends."""
return OpenAI(base_url=settings.local_base_url, api_key="ollama")
def _model() -> str:
return settings.local_model
def generate_text(
system_instruction: str,
user_text: str,
*,
max_output_tokens: int = 512,
temperature: float = 0.0,
) -> str:
"""Single-shot text completion (planning, NLβSQL). Mirrors the claude/gemini
backends so the dispatcher can pick any provider."""
resp = get_local_client().chat.completions.create(
model=_model(),
messages=[
{"role": "system", "content": system_instruction},
{"role": "user", "content": user_text},
],
max_tokens=max_output_tokens,
temperature=temperature,
)
return resp.choices[0].message.content or ""
def synthesize_local(question: str, chunks: list[RetrievedChunk]) -> SynthesisResult:
if not chunks:
return empty_result(_model())
resp = get_local_client().chat.completions.create(
model=_model(),
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_message(question, chunks)},
],
max_tokens=MAX_TOKENS,
temperature=0.0,
)
choice = resp.choices[0]
usage = resp.usage
return SynthesisResult(
answer=choice.message.content or "",
model=_model(),
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
# Local inference has no cache-billing tier; keep both at 0.
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
stop_reason=choice.finish_reason or "unknown",
)
# ββ Agent tool-loop (OpenAI-style function calling) ββββββββββββββββββββββββββ
def _openai_tools() -> list[dict]:
"""ToolSpec registry β OpenAI tool schema. ToolSpec.parameters are already
JSON-schema, which is exactly the `function.parameters` shape."""
from finrag.tools import TOOL_SPECS # lazy: avoid llmβtools import cycle
return [
{
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters,
},
}
for s in TOOL_SPECS
]
def _synthesis_only(system: str, user_text: str, max_tokens: int) -> ToolLoopResult:
"""Degraded path: no tools offered, just grounded synthesis over the context
already embedded in `user_text`. This is what a 3B model does reliably, so we
benchmark it as the honest edge finding when tool-calling is off."""
resp = get_local_client().chat.completions.create(
model=_model(),
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_text},
],
max_tokens=max_tokens,
temperature=0.0,
)
usage = resp.usage
return ToolLoopResult(
answer=resp.choices[0].message.content or "",
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
tool_calls=[],
)
def tool_loop(
system: str,
user_text: str,
*,
max_tokens: int = 1024,
max_iters: int = 5,
) -> ToolLoopResult:
"""Run the local model with tools until it stops requesting them (or
max_iters). Mirrors claude/gemini tool_loop's signature/return.
When settings.local_use_tools is False, skip tool-calling entirely and run
synthesis-only β the documented degraded mode for small models."""
if not settings.local_use_tools:
return _synthesis_only(system, user_text, max_tokens)
from finrag.tools import dispatch # lazy: avoid llmβtools import cycle
tools = _openai_tools()
messages: list[dict] = [
{"role": "system", "content": system},
{"role": "user", "content": user_text},
]
in_tok = out_tok = 0
calls: list[ToolCall] = []
answer = ""
for _ in range(max_iters):
resp = get_local_client().chat.completions.create(
model=_model(),
messages=messages,
tools=tools,
max_tokens=max_tokens,
temperature=0.0,
)
msg = resp.choices[0].message
usage = resp.usage
in_tok += getattr(usage, "prompt_tokens", 0) or 0
out_tok += getattr(usage, "completion_tokens", 0) or 0
if msg.tool_calls:
# Re-send the assistant turn verbatim (content + the tool_calls it
# requested), then one tool message per call, keyed by tool_call_id.
messages.append(
{
"role": "assistant",
"content": msg.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
],
}
)
for tc in msg.tool_calls:
# 3B models sometimes emit malformed JSON args β treat as empty
# rather than crashing the loop (part of the weak-tool-calling story).
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
result = json_safe(dispatch(tc.function.name, args))
calls.append(ToolCall(tc.function.name, args, result))
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
}
)
continue
answer = msg.content or ""
break
return ToolLoopResult(
answer=answer, input_tokens=in_tok, output_tokens=out_tok, tool_calls=calls
)
|