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
    )