feedsummary-core 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- feedsummary_core/__init__.py +38 -0
- feedsummary_core/_version.py +34 -0
- feedsummary_core/llm_client/__init__.py +94 -0
- feedsummary_core/llm_client/fallback_client.py +113 -0
- feedsummary_core/llm_client/ollama_cloud.py +209 -0
- feedsummary_core/llm_client/ollama_local.py +214 -0
- feedsummary_core/persistence/SqliteStore.py +679 -0
- feedsummary_core/persistence/TinyDbStore.py +292 -0
- feedsummary_core/persistence/__init__.py +111 -0
- feedsummary_core/summarizer/__init__.py +32 -0
- feedsummary_core/summarizer/batching.py +378 -0
- feedsummary_core/summarizer/chat.py +113 -0
- feedsummary_core/summarizer/helpers.py +490 -0
- feedsummary_core/summarizer/ingest.py +326 -0
- feedsummary_core/summarizer/main.py +900 -0
- feedsummary_core/summarizer/prompt_lab.py +424 -0
- feedsummary_core/summarizer/prompt_replay.py +507 -0
- feedsummary_core/summarizer/summarizer.py +941 -0
- feedsummary_core/summarizer/token_budget.py +96 -0
- feedsummary_core-1.0.2.dist-info/METADATA +26 -0
- feedsummary_core-1.0.2.dist-info/RECORD +24 -0
- feedsummary_core-1.0.2.dist-info/WHEEL +5 -0
- feedsummary_core-1.0.2.dist-info/licenses/LICENSE +28 -0
- feedsummary_core-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# LICENSE HEADER MANAGED BY add-license-header
|
|
2
|
+
#
|
|
3
|
+
# BSD 3-Clause License
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) 2026, Martin Vesterlund
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions are met:
|
|
9
|
+
#
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
# list of conditions and the following disclaimer.
|
|
12
|
+
#
|
|
13
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
# and/or other materials provided with the distribution.
|
|
16
|
+
#
|
|
17
|
+
# 3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
# contributors may be used to endorse or promote products derived from
|
|
19
|
+
# this software without specific prior written permission.
|
|
20
|
+
#
|
|
21
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
#
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from ._version import version as __version__
|
|
35
|
+
except Exception:
|
|
36
|
+
__version__ = "0+unknown"
|
|
37
|
+
|
|
38
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '1.0.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 0, 2)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# LICENSE HEADER MANAGED BY add-license-header
|
|
2
|
+
#
|
|
3
|
+
# BSD 3-Clause License
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) 2026, Martin Vesterlund
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions are met:
|
|
9
|
+
#
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
# list of conditions and the following disclaimer.
|
|
12
|
+
#
|
|
13
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
# and/or other materials provided with the distribution.
|
|
16
|
+
#
|
|
17
|
+
# 3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
# contributors may be used to endorse or promote products derived from
|
|
19
|
+
# this software without specific prior written permission.
|
|
20
|
+
#
|
|
21
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
#
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from llmClient.fallback_client import FallbackLLMClient, FallbackPolicy
|
|
35
|
+
|
|
36
|
+
from typing import Any, Dict, List, Optional, Protocol
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LLMError(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LLMClient(Protocol):
|
|
44
|
+
async def chat(self, messages: List[Dict[str, str]], *, temperature: float = 0.2) -> str: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _create_single_llm(llm_cfg: Dict[str, Any]):
|
|
48
|
+
provider = (llm_cfg.get("provider") or "ollama").lower()
|
|
49
|
+
|
|
50
|
+
if provider == "ollama_cloud":
|
|
51
|
+
from llmClient.ollama_cloud import OllamaCloudClient
|
|
52
|
+
|
|
53
|
+
return OllamaCloudClient(llm_cfg)
|
|
54
|
+
|
|
55
|
+
if provider == "ollama_local":
|
|
56
|
+
from llmClient.ollama_local import OllamaLocalClient, OllamaConfig
|
|
57
|
+
|
|
58
|
+
cfg = OllamaConfig(
|
|
59
|
+
base_url=str(llm_cfg.get("base_url", "http://localhost:11434")),
|
|
60
|
+
model=str(llm_cfg.get("model", "llama3.1:latest")),
|
|
61
|
+
max_rps=float(llm_cfg.get("max_rps", 1.0)),
|
|
62
|
+
first_byte_timeout_s=int(llm_cfg.get("first_byte_timeout_s", 900)),
|
|
63
|
+
sock_read_timeout_s=int(llm_cfg.get("sock_read_timeout_s", 300)),
|
|
64
|
+
progress_log_every_s=float(llm_cfg.get("progress_log_every_s", 2.0)),
|
|
65
|
+
max_retries=int(llm_cfg.get("max_retries", 3)),
|
|
66
|
+
)
|
|
67
|
+
return OllamaLocalClient(cfg)
|
|
68
|
+
|
|
69
|
+
raise ValueError(f"Unsupported LLM provider: {provider}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_llm_client(config: Dict[str, Any]):
|
|
73
|
+
"""
|
|
74
|
+
Bygger primary från config['llm'] och (om finns) fallback från config['llm_fallback'].
|
|
75
|
+
|
|
76
|
+
Fallback triggas när cloud slår i quota igen efter retry.
|
|
77
|
+
"""
|
|
78
|
+
llm_cfg = config.get("llm") or {}
|
|
79
|
+
primary = _create_single_llm(llm_cfg)
|
|
80
|
+
|
|
81
|
+
fallback_cfg: Optional[Dict[str, Any]] = config.get("llm_fallback")
|
|
82
|
+
fallback = _create_single_llm(fallback_cfg) if isinstance(fallback_cfg, dict) else None
|
|
83
|
+
|
|
84
|
+
# policy kan ligga under llm.quota eller llm_fallback_policy – välj det du gillar
|
|
85
|
+
quota_cfg = llm_cfg.get("quota") or {}
|
|
86
|
+
policy = FallbackPolicy(
|
|
87
|
+
max_quota_retries=int(quota_cfg.get("max_quota_retries", 1)),
|
|
88
|
+
default_wait_s=int(quota_cfg.get("default_wait_s", 30)),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if fallback:
|
|
92
|
+
return FallbackLLMClient(primary=primary, fallback=fallback, policy=policy)
|
|
93
|
+
|
|
94
|
+
return primary
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# LICENSE HEADER MANAGED BY add-license-header
|
|
2
|
+
#
|
|
3
|
+
# BSD 3-Clause License
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) 2026, Martin Vesterlund
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions are met:
|
|
9
|
+
#
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
# list of conditions and the following disclaimer.
|
|
12
|
+
#
|
|
13
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
# and/or other materials provided with the distribution.
|
|
16
|
+
#
|
|
17
|
+
# 3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
# contributors may be used to endorse or promote products derived from
|
|
19
|
+
# this software without specific prior written permission.
|
|
20
|
+
#
|
|
21
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
#
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import logging
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Dict, List, Optional, Protocol
|
|
39
|
+
|
|
40
|
+
from llm_client import LLMRateLimitError
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LLMClient(Protocol):
|
|
46
|
+
async def chat(self, messages: List[Dict[str, str]], *, temperature: float = 0.2) -> str: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class FallbackPolicy:
|
|
51
|
+
max_quota_retries: int = 1 # "vid retry" -> om det slår i igen efter 1 retry: fallback
|
|
52
|
+
default_wait_s: int = 30 # om retry_after saknas
|
|
53
|
+
jitter_s: float = 0.0 # håll 0 om du vill vara deterministisk
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FallbackLLMClient:
|
|
57
|
+
"""
|
|
58
|
+
Wrapper som kör primary, men om quota/rate-limit kvarstår efter retry -> fallback.
|
|
59
|
+
|
|
60
|
+
Beteende:
|
|
61
|
+
- Får vi LLMRateLimitError:
|
|
62
|
+
* vänta retry_after (eller default_wait_s)
|
|
63
|
+
* försök igen upp till max_quota_retries
|
|
64
|
+
- Om fortfarande rate limited:
|
|
65
|
+
* växla permanent till fallback (för resten av processen)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
primary: LLMClient,
|
|
71
|
+
fallback: Optional[LLMClient],
|
|
72
|
+
policy: Optional[FallbackPolicy] = None,
|
|
73
|
+
):
|
|
74
|
+
self.primary = primary
|
|
75
|
+
self.fallback = fallback
|
|
76
|
+
self.policy = policy or FallbackPolicy()
|
|
77
|
+
self._force_fallback = False
|
|
78
|
+
|
|
79
|
+
async def chat(self, messages: List[Dict[str, str]], *, temperature: float = 0.2) -> str:
|
|
80
|
+
# Om vi redan växlat till fallback
|
|
81
|
+
if self._force_fallback:
|
|
82
|
+
if not self.fallback:
|
|
83
|
+
raise RuntimeError("Fallback aktiverad men llm_fallback saknas i config.")
|
|
84
|
+
return await self.fallback.chat(messages, temperature=temperature)
|
|
85
|
+
|
|
86
|
+
# Försök primary med quota-retries
|
|
87
|
+
attempt = 0
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
return await self.primary.chat(messages, temperature=temperature)
|
|
91
|
+
except LLMRateLimitError as e:
|
|
92
|
+
attempt += 1
|
|
93
|
+
wait_s = int(e.retry_after_seconds or self.policy.default_wait_s)
|
|
94
|
+
|
|
95
|
+
# Om vi redan testat retry och slår i igen -> fallback
|
|
96
|
+
if attempt > self.policy.max_quota_retries:
|
|
97
|
+
if not self.fallback:
|
|
98
|
+
# ingen fallback konfigurerad
|
|
99
|
+
raise
|
|
100
|
+
log.warning(
|
|
101
|
+
"LLM quota/rate-limit kvarstår efter %d retry(s). Växlar till fallback.",
|
|
102
|
+
self.policy.max_quota_retries,
|
|
103
|
+
)
|
|
104
|
+
self._force_fallback = True
|
|
105
|
+
return await self.fallback.chat(messages, temperature=temperature)
|
|
106
|
+
|
|
107
|
+
log.warning(
|
|
108
|
+
"LLM rate-limited (attempt %d/%d). Väntar %ss och retry...",
|
|
109
|
+
attempt,
|
|
110
|
+
self.policy.max_quota_retries,
|
|
111
|
+
wait_s,
|
|
112
|
+
)
|
|
113
|
+
await asyncio.sleep(wait_s)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# LICENSE HEADER MANAGED BY add-license-header
|
|
2
|
+
#
|
|
3
|
+
# BSD 3-Clause License
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) 2026, Martin Vesterlund
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions are met:
|
|
9
|
+
#
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
# list of conditions and the following disclaimer.
|
|
12
|
+
#
|
|
13
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
# and/or other materials provided with the distribution.
|
|
16
|
+
#
|
|
17
|
+
# 3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
# contributors may be used to endorse or promote products derived from
|
|
19
|
+
# this software without specific prior written permission.
|
|
20
|
+
#
|
|
21
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
#
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Any, Dict, List, Optional
|
|
40
|
+
import logging
|
|
41
|
+
from ollama import AsyncClient
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LLMRateLimitError(RuntimeError):
|
|
47
|
+
def __init__(self, message: str, retry_after_seconds: Optional[int] = None):
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.retry_after_seconds = retry_after_seconds
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LLMAuthError(RuntimeError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LLMUnavailableError(RuntimeError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class OllamaCloudConfig:
|
|
62
|
+
host: str = "https://ollama.com"
|
|
63
|
+
model: str = "gemma3:270m"
|
|
64
|
+
api_key: str = ""
|
|
65
|
+
# quota/preflight
|
|
66
|
+
preflight: bool = True
|
|
67
|
+
min_interval_seconds: float = 1.0
|
|
68
|
+
timeout_seconds: float = 180.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_env(value: str) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Stöd för ${VAR} och $VAR i config.yaml.
|
|
74
|
+
"""
|
|
75
|
+
if not isinstance(value, str):
|
|
76
|
+
return str(value)
|
|
77
|
+
return os.path.expandvars(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_retry_after_seconds(exc: Exception) -> Optional[int]:
|
|
81
|
+
"""
|
|
82
|
+
Försök läsa Retry-After från exception/response om biblioteket exponerar det.
|
|
83
|
+
(Ollama-python’s exceptions kan variera mellan versioner.)
|
|
84
|
+
"""
|
|
85
|
+
# ResponseError i ollama-python brukar ha status_code + error, men inte alltid headers.
|
|
86
|
+
# Vi gör därför "best effort".
|
|
87
|
+
for attr in ("retry_after", "retry_after_seconds"):
|
|
88
|
+
v = getattr(exc, attr, None)
|
|
89
|
+
if isinstance(v, int):
|
|
90
|
+
return v
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_status(exc: Exception, code: int) -> bool:
|
|
95
|
+
sc = getattr(exc, "status_code", None)
|
|
96
|
+
if isinstance(sc, int) and sc == code:
|
|
97
|
+
return True
|
|
98
|
+
# vissa varianter använder .status
|
|
99
|
+
st = getattr(exc, "status", None)
|
|
100
|
+
return isinstance(st, int) and st == code
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class OllamaCloudClient:
|
|
104
|
+
"""
|
|
105
|
+
LLMClient-implementation för Ollama Cloud API med gemma3:270m.
|
|
106
|
+
|
|
107
|
+
Använder officiella 'ollama' AsyncClient och kör mot host=https://ollama.com
|
|
108
|
+
med Authorization Bearer API key. :contentReference[oaicite:3]{index=3}
|
|
109
|
+
|
|
110
|
+
"Quota check" görs via preflight call till /api/tags (client.list()).
|
|
111
|
+
Det ger tidig signal för 401/429 innan vi drar igång stora prompts.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, cfg: Dict[str, Any]):
|
|
115
|
+
llm_cfg = cfg or {}
|
|
116
|
+
quota_cfg = llm_cfg.get("quota") or {}
|
|
117
|
+
|
|
118
|
+
self.cfg = OllamaCloudConfig(
|
|
119
|
+
host=str(llm_cfg.get("host", "https://ollama.com")),
|
|
120
|
+
model=str(llm_cfg.get("model", "gemma3:270m")),
|
|
121
|
+
api_key=_resolve_env(str(llm_cfg.get("api_key", ""))),
|
|
122
|
+
preflight=bool(quota_cfg.get("preflight", True)),
|
|
123
|
+
min_interval_seconds=float(quota_cfg.get("min_interval_seconds", 1.0)),
|
|
124
|
+
timeout_seconds=float(llm_cfg.get("timeout_seconds", 360.0)),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if not self.cfg.api_key:
|
|
128
|
+
raise LLMAuthError("Ollama Cloud kräver api_key i config.yaml (eller via env-var).")
|
|
129
|
+
|
|
130
|
+
self._client = AsyncClient(
|
|
131
|
+
host=self.cfg.host,
|
|
132
|
+
headers={"Authorization": f"Bearer {self.cfg.api_key}"},
|
|
133
|
+
timeout=self.cfg.timeout_seconds,
|
|
134
|
+
)
|
|
135
|
+
self.log = logging.getLogger(__name__)
|
|
136
|
+
self._last_call_ts: float = 0.0
|
|
137
|
+
self._preflight_ok_ts: float = 0.0
|
|
138
|
+
|
|
139
|
+
async def _throttle(self) -> None:
|
|
140
|
+
now = time.time()
|
|
141
|
+
dt = now - self._last_call_ts
|
|
142
|
+
if dt < self.cfg.min_interval_seconds:
|
|
143
|
+
await asyncio.sleep(self.cfg.min_interval_seconds - dt)
|
|
144
|
+
self._last_call_ts = time.time()
|
|
145
|
+
|
|
146
|
+
async def _preflight_quota_check(self) -> None:
|
|
147
|
+
if not self.cfg.preflight:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Kör inte preflight onödigt ofta
|
|
151
|
+
now = time.time()
|
|
152
|
+
if (now - self._preflight_ok_ts) < 10.0:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
# list() motsvarar /api/tags och kräver auth på ollama.com
|
|
157
|
+
# :contentReference[oaicite:4]{index=4}
|
|
158
|
+
await self._client.list()
|
|
159
|
+
self._preflight_ok_ts = time.time()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
if _is_status(e, 401) or _is_status(e, 403):
|
|
162
|
+
raise LLMAuthError("Ollama Cloud auth misslyckades (api_key fel/nekad).") from e
|
|
163
|
+
if _is_status(e, 429):
|
|
164
|
+
ra = _extract_retry_after_seconds(e)
|
|
165
|
+
raise LLMRateLimitError(
|
|
166
|
+
"Ollama Cloud: rate limit/quota uppnådd (preflight).", ra
|
|
167
|
+
) from e
|
|
168
|
+
raise LLMUnavailableError(f"Ollama Cloud preflight misslyckades: {e}") from e
|
|
169
|
+
|
|
170
|
+
async def chat(self, messages: List[Dict[str, str]], temperature: float = 0.2) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Returnerar en enda textsträng (sammanfogad content).
|
|
173
|
+
Hanterar preflight quota-check och 429/timeout tydligt.
|
|
174
|
+
"""
|
|
175
|
+
await self._throttle()
|
|
176
|
+
await self._preflight_quota_check()
|
|
177
|
+
self.log.info("LLM request start (model=%s)", self.cfg.model)
|
|
178
|
+
# Ollama API använder "options" för parametrar.
|
|
179
|
+
# (temperature är ett standard-alternativ i Ollama.)
|
|
180
|
+
payload_options = {"temperature": temperature}
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
resp = await self._client.chat(
|
|
184
|
+
model=self.cfg.model,
|
|
185
|
+
messages=messages,
|
|
186
|
+
stream=False,
|
|
187
|
+
options=payload_options,
|
|
188
|
+
)
|
|
189
|
+
# resp kan vara dict-lik eller typed; vi stödjer båda:
|
|
190
|
+
if isinstance(resp, dict):
|
|
191
|
+
return (resp.get("message") or {}).get("content", "") or ""
|
|
192
|
+
return getattr(resp.message, "content", "") or ""
|
|
193
|
+
except Exception as e:
|
|
194
|
+
try:
|
|
195
|
+
body = getattr(e, "error", None) or getattr(e, "message", None) or str(e)
|
|
196
|
+
if isinstance(body, str) and body.strip():
|
|
197
|
+
self.log.error("Ollama Cloud error response/body:\n%s", body[:12000])
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
if _is_status(e, 401) or _is_status(e, 403):
|
|
201
|
+
raise LLMAuthError("Ollama Cloud: auth misslyckades (api_key fel/nekad).") from e
|
|
202
|
+
if _is_status(e, 429):
|
|
203
|
+
ra = _extract_retry_after_seconds(e)
|
|
204
|
+
raise LLMRateLimitError("Ollama Cloud: rate limit/quota uppnådd.", ra) from e
|
|
205
|
+
# timeouts kan komma som httpx.TimeoutException under huven
|
|
206
|
+
name = e.__class__.__name__.lower()
|
|
207
|
+
if "timeout" in name:
|
|
208
|
+
raise LLMUnavailableError(f"Ollama Cloud: timeout: {e}") from e
|
|
209
|
+
raise LLMUnavailableError(f"Ollama Cloud: chat misslyckades: {e}") from e
|