kyber-chat 1.0.0__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.
- kyber/__init__.py +6 -0
- kyber/__main__.py +8 -0
- kyber/agent/__init__.py +8 -0
- kyber/agent/context.py +224 -0
- kyber/agent/loop.py +687 -0
- kyber/agent/memory.py +109 -0
- kyber/agent/skills.py +244 -0
- kyber/agent/subagent.py +379 -0
- kyber/agent/tools/__init__.py +6 -0
- kyber/agent/tools/base.py +102 -0
- kyber/agent/tools/filesystem.py +191 -0
- kyber/agent/tools/message.py +86 -0
- kyber/agent/tools/registry.py +73 -0
- kyber/agent/tools/shell.py +141 -0
- kyber/agent/tools/spawn.py +65 -0
- kyber/agent/tools/task_status.py +53 -0
- kyber/agent/tools/web.py +163 -0
- kyber/bridge/package.json +26 -0
- kyber/bridge/src/index.ts +50 -0
- kyber/bridge/src/server.ts +104 -0
- kyber/bridge/src/types.d.ts +3 -0
- kyber/bridge/src/whatsapp.ts +185 -0
- kyber/bridge/tsconfig.json +16 -0
- kyber/bus/__init__.py +6 -0
- kyber/bus/events.py +37 -0
- kyber/bus/queue.py +81 -0
- kyber/channels/__init__.py +6 -0
- kyber/channels/base.py +121 -0
- kyber/channels/discord.py +304 -0
- kyber/channels/feishu.py +263 -0
- kyber/channels/manager.py +161 -0
- kyber/channels/telegram.py +302 -0
- kyber/channels/whatsapp.py +141 -0
- kyber/cli/__init__.py +1 -0
- kyber/cli/commands.py +736 -0
- kyber/config/__init__.py +6 -0
- kyber/config/loader.py +95 -0
- kyber/config/schema.py +205 -0
- kyber/cron/__init__.py +6 -0
- kyber/cron/service.py +346 -0
- kyber/cron/types.py +59 -0
- kyber/dashboard/__init__.py +5 -0
- kyber/dashboard/server.py +122 -0
- kyber/dashboard/static/app.js +458 -0
- kyber/dashboard/static/favicon.png +0 -0
- kyber/dashboard/static/index.html +107 -0
- kyber/dashboard/static/kyber_logo.png +0 -0
- kyber/dashboard/static/styles.css +608 -0
- kyber/heartbeat/__init__.py +5 -0
- kyber/heartbeat/service.py +130 -0
- kyber/providers/__init__.py +6 -0
- kyber/providers/base.py +69 -0
- kyber/providers/litellm_provider.py +227 -0
- kyber/providers/transcription.py +65 -0
- kyber/session/__init__.py +5 -0
- kyber/session/manager.py +202 -0
- kyber/skills/README.md +47 -0
- kyber/skills/github/SKILL.md +48 -0
- kyber/skills/skill-creator/SKILL.md +371 -0
- kyber/skills/summarize/SKILL.md +67 -0
- kyber/skills/tmux/SKILL.md +121 -0
- kyber/skills/tmux/scripts/find-sessions.sh +112 -0
- kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
- kyber/skills/weather/SKILL.md +49 -0
- kyber/utils/__init__.py +5 -0
- kyber/utils/helpers.py +91 -0
- kyber_chat-1.0.0.dist-info/METADATA +35 -0
- kyber_chat-1.0.0.dist-info/RECORD +71 -0
- kyber_chat-1.0.0.dist-info/WHEEL +4 -0
- kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
- kyber_chat-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Heartbeat service - periodic agent wake-up to check for tasks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Coroutine
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
# Default interval: 30 minutes
|
|
10
|
+
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
|
|
11
|
+
|
|
12
|
+
# The prompt sent to agent during heartbeat
|
|
13
|
+
HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists).
|
|
14
|
+
Follow any instructions or tasks listed there.
|
|
15
|
+
If nothing needs attention, reply with just: HEARTBEAT_OK"""
|
|
16
|
+
|
|
17
|
+
# Token that indicates "nothing to do"
|
|
18
|
+
HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_heartbeat_empty(content: str | None) -> bool:
|
|
22
|
+
"""Check if HEARTBEAT.md has no actionable content."""
|
|
23
|
+
if not content:
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
# Lines to skip: empty, headers, HTML comments, empty checkboxes
|
|
27
|
+
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
|
|
28
|
+
|
|
29
|
+
for line in content.split("\n"):
|
|
30
|
+
line = line.strip()
|
|
31
|
+
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
|
|
32
|
+
continue
|
|
33
|
+
return False # Found actionable content
|
|
34
|
+
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HeartbeatService:
|
|
39
|
+
"""
|
|
40
|
+
Periodic heartbeat service that wakes the agent to check for tasks.
|
|
41
|
+
|
|
42
|
+
The agent reads HEARTBEAT.md from the workspace and executes any
|
|
43
|
+
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
workspace: Path,
|
|
49
|
+
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
|
50
|
+
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
|
|
51
|
+
enabled: bool = True,
|
|
52
|
+
):
|
|
53
|
+
self.workspace = workspace
|
|
54
|
+
self.on_heartbeat = on_heartbeat
|
|
55
|
+
self.interval_s = interval_s
|
|
56
|
+
self.enabled = enabled
|
|
57
|
+
self._running = False
|
|
58
|
+
self._task: asyncio.Task | None = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def heartbeat_file(self) -> Path:
|
|
62
|
+
return self.workspace / "HEARTBEAT.md"
|
|
63
|
+
|
|
64
|
+
def _read_heartbeat_file(self) -> str | None:
|
|
65
|
+
"""Read HEARTBEAT.md content."""
|
|
66
|
+
if self.heartbeat_file.exists():
|
|
67
|
+
try:
|
|
68
|
+
return self.heartbeat_file.read_text()
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
async def start(self) -> None:
|
|
74
|
+
"""Start the heartbeat service."""
|
|
75
|
+
if not self.enabled:
|
|
76
|
+
logger.info("Heartbeat disabled")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
self._running = True
|
|
80
|
+
self._task = asyncio.create_task(self._run_loop())
|
|
81
|
+
logger.info(f"Heartbeat started (every {self.interval_s}s)")
|
|
82
|
+
|
|
83
|
+
def stop(self) -> None:
|
|
84
|
+
"""Stop the heartbeat service."""
|
|
85
|
+
self._running = False
|
|
86
|
+
if self._task:
|
|
87
|
+
self._task.cancel()
|
|
88
|
+
self._task = None
|
|
89
|
+
|
|
90
|
+
async def _run_loop(self) -> None:
|
|
91
|
+
"""Main heartbeat loop."""
|
|
92
|
+
while self._running:
|
|
93
|
+
try:
|
|
94
|
+
await asyncio.sleep(self.interval_s)
|
|
95
|
+
if self._running:
|
|
96
|
+
await self._tick()
|
|
97
|
+
except asyncio.CancelledError:
|
|
98
|
+
break
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.error(f"Heartbeat error: {e}")
|
|
101
|
+
|
|
102
|
+
async def _tick(self) -> None:
|
|
103
|
+
"""Execute a single heartbeat tick."""
|
|
104
|
+
content = self._read_heartbeat_file()
|
|
105
|
+
|
|
106
|
+
# Skip if HEARTBEAT.md is empty or doesn't exist
|
|
107
|
+
if _is_heartbeat_empty(content):
|
|
108
|
+
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
logger.info("Heartbeat: checking for tasks...")
|
|
112
|
+
|
|
113
|
+
if self.on_heartbeat:
|
|
114
|
+
try:
|
|
115
|
+
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
|
|
116
|
+
|
|
117
|
+
# Check if agent said "nothing to do"
|
|
118
|
+
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
|
|
119
|
+
logger.info("Heartbeat: OK (no action needed)")
|
|
120
|
+
else:
|
|
121
|
+
logger.info(f"Heartbeat: completed task")
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.error(f"Heartbeat execution failed: {e}")
|
|
125
|
+
|
|
126
|
+
async def trigger_now(self) -> str | None:
|
|
127
|
+
"""Manually trigger a heartbeat."""
|
|
128
|
+
if self.on_heartbeat:
|
|
129
|
+
return await self.on_heartbeat(HEARTBEAT_PROMPT)
|
|
130
|
+
return None
|
kyber/providers/base.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Base LLM provider interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ToolCallRequest:
|
|
10
|
+
"""A tool call request from the LLM."""
|
|
11
|
+
id: str
|
|
12
|
+
name: str
|
|
13
|
+
arguments: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LLMResponse:
|
|
18
|
+
"""Response from an LLM provider."""
|
|
19
|
+
content: str | None
|
|
20
|
+
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
|
21
|
+
finish_reason: str = "stop"
|
|
22
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def has_tool_calls(self) -> bool:
|
|
26
|
+
"""Check if response contains tool calls."""
|
|
27
|
+
return len(self.tool_calls) > 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LLMProvider(ABC):
|
|
31
|
+
"""
|
|
32
|
+
Abstract base class for LLM providers.
|
|
33
|
+
|
|
34
|
+
Implementations should handle the specifics of each provider's API
|
|
35
|
+
while maintaining a consistent interface.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
|
39
|
+
self.api_key = api_key
|
|
40
|
+
self.api_base = api_base
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def chat(
|
|
44
|
+
self,
|
|
45
|
+
messages: list[dict[str, Any]],
|
|
46
|
+
tools: list[dict[str, Any]] | None = None,
|
|
47
|
+
model: str | None = None,
|
|
48
|
+
max_tokens: int = 4096,
|
|
49
|
+
temperature: float = 0.7,
|
|
50
|
+
) -> LLMResponse:
|
|
51
|
+
"""
|
|
52
|
+
Send a chat completion request.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
messages: List of message dicts with 'role' and 'content'.
|
|
56
|
+
tools: Optional list of tool definitions.
|
|
57
|
+
model: Model identifier (provider-specific).
|
|
58
|
+
max_tokens: Maximum tokens in response.
|
|
59
|
+
temperature: Sampling temperature.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
LLMResponse with content and/or tool calls.
|
|
63
|
+
"""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def get_default_model(self) -> str:
|
|
68
|
+
"""Get the default model for this provider."""
|
|
69
|
+
pass
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""LiteLLM provider implementation for multi-provider support."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import litellm
|
|
7
|
+
from litellm import acompletion
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from kyber.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LiteLLMProvider(LLMProvider):
|
|
14
|
+
"""
|
|
15
|
+
LLM provider using LiteLLM for multi-provider support.
|
|
16
|
+
|
|
17
|
+
Supports OpenRouter, Anthropic, OpenAI, Gemini, and many other providers through
|
|
18
|
+
a unified interface.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
api_base: str | None = None,
|
|
25
|
+
default_model: str = "anthropic/claude-opus-4-5",
|
|
26
|
+
provider_name: str | None = None,
|
|
27
|
+
):
|
|
28
|
+
super().__init__(api_key, api_base)
|
|
29
|
+
self.default_model = default_model
|
|
30
|
+
self.provider_name = provider_name.strip().lower() if provider_name else None
|
|
31
|
+
|
|
32
|
+
if self.provider_name:
|
|
33
|
+
self.is_openrouter = self.provider_name == "openrouter"
|
|
34
|
+
self.is_vllm = self.provider_name == "vllm"
|
|
35
|
+
else:
|
|
36
|
+
# Detect OpenRouter by api_key prefix or explicit api_base
|
|
37
|
+
self.is_openrouter = (
|
|
38
|
+
(api_key and api_key.startswith("sk-or-")) or
|
|
39
|
+
(api_base and "openrouter" in api_base)
|
|
40
|
+
)
|
|
41
|
+
# Track if using custom endpoint (vLLM, etc.)
|
|
42
|
+
self.is_vllm = bool(api_base) and not self.is_openrouter
|
|
43
|
+
|
|
44
|
+
# Configure LiteLLM based on provider
|
|
45
|
+
if api_key:
|
|
46
|
+
if self.is_openrouter:
|
|
47
|
+
# OpenRouter mode - set key
|
|
48
|
+
os.environ["OPENROUTER_API_KEY"] = api_key
|
|
49
|
+
elif self.is_vllm:
|
|
50
|
+
# vLLM/custom endpoint - uses OpenAI-compatible API
|
|
51
|
+
os.environ.setdefault("OPENAI_API_KEY", api_key)
|
|
52
|
+
elif self.provider_name == "deepseek" or "deepseek" in default_model:
|
|
53
|
+
os.environ.setdefault("DEEPSEEK_API_KEY", api_key)
|
|
54
|
+
elif self.provider_name == "anthropic" or "anthropic" in default_model:
|
|
55
|
+
os.environ.setdefault("ANTHROPIC_API_KEY", api_key)
|
|
56
|
+
elif self.provider_name == "openai" or "openai" in default_model or "gpt" in default_model:
|
|
57
|
+
os.environ.setdefault("OPENAI_API_KEY", api_key)
|
|
58
|
+
elif self.provider_name == "gemini" or "gemini" in default_model.lower():
|
|
59
|
+
os.environ.setdefault("GEMINI_API_KEY", api_key)
|
|
60
|
+
elif self.provider_name == "zhipu" or "glm" in default_model or "zhipu" in default_model or "zai" in default_model:
|
|
61
|
+
os.environ.setdefault("ZHIPUAI_API_KEY", api_key)
|
|
62
|
+
elif self.provider_name == "groq" or "groq" in default_model:
|
|
63
|
+
os.environ.setdefault("GROQ_API_KEY", api_key)
|
|
64
|
+
|
|
65
|
+
if api_base:
|
|
66
|
+
litellm.api_base = api_base
|
|
67
|
+
|
|
68
|
+
# Disable LiteLLM logging noise
|
|
69
|
+
litellm.suppress_debug_info = True
|
|
70
|
+
|
|
71
|
+
async def chat(
|
|
72
|
+
self,
|
|
73
|
+
messages: list[dict[str, Any]],
|
|
74
|
+
tools: list[dict[str, Any]] | None = None,
|
|
75
|
+
model: str | None = None,
|
|
76
|
+
max_tokens: int = 4096,
|
|
77
|
+
temperature: float = 0.7,
|
|
78
|
+
) -> LLMResponse:
|
|
79
|
+
"""
|
|
80
|
+
Send a chat completion request via LiteLLM.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
messages: List of message dicts with 'role' and 'content'.
|
|
84
|
+
tools: Optional list of tool definitions in OpenAI format.
|
|
85
|
+
model: Model identifier (e.g., 'anthropic/claude-sonnet-4-5').
|
|
86
|
+
max_tokens: Maximum tokens in response.
|
|
87
|
+
temperature: Sampling temperature.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
LLMResponse with content and/or tool calls.
|
|
91
|
+
"""
|
|
92
|
+
model = model or self.default_model
|
|
93
|
+
|
|
94
|
+
# For OpenRouter, prefix model name if not already prefixed
|
|
95
|
+
if self.is_openrouter and not model.startswith("openrouter/"):
|
|
96
|
+
model = f"openrouter/{model}"
|
|
97
|
+
|
|
98
|
+
# For Zhipu/Z.ai, ensure prefix is present
|
|
99
|
+
# Handle cases like "glm-4.7-flash" -> "zai/glm-4.7-flash"
|
|
100
|
+
if ("glm" in model.lower() or "zhipu" in model.lower()) and not (
|
|
101
|
+
model.startswith("zhipu/") or
|
|
102
|
+
model.startswith("zai/") or
|
|
103
|
+
model.startswith("openrouter/")
|
|
104
|
+
):
|
|
105
|
+
model = f"zai/{model}"
|
|
106
|
+
|
|
107
|
+
# For vLLM, use hosted_vllm/ prefix per LiteLLM docs
|
|
108
|
+
if self.is_vllm:
|
|
109
|
+
model = f"hosted_vllm/{model}"
|
|
110
|
+
|
|
111
|
+
# For Gemini, ensure gemini/ prefix if not already present.
|
|
112
|
+
# Skip if routing via OpenRouter (openrouter/...) since that provider handles it.
|
|
113
|
+
if (
|
|
114
|
+
"gemini" in model.lower()
|
|
115
|
+
and not model.startswith("gemini/")
|
|
116
|
+
and not model.startswith("openrouter/")
|
|
117
|
+
):
|
|
118
|
+
model = f"gemini/{model}"
|
|
119
|
+
|
|
120
|
+
kwargs: dict[str, Any] = {
|
|
121
|
+
"model": model,
|
|
122
|
+
"messages": messages,
|
|
123
|
+
"max_tokens": max_tokens,
|
|
124
|
+
"temperature": temperature,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Pass api_base directly for custom endpoints (vLLM, etc.)
|
|
128
|
+
if self.api_base:
|
|
129
|
+
kwargs["api_base"] = self.api_base
|
|
130
|
+
|
|
131
|
+
if tools:
|
|
132
|
+
kwargs["tools"] = tools
|
|
133
|
+
kwargs["tool_choice"] = "auto"
|
|
134
|
+
|
|
135
|
+
last_error: Exception | None = None
|
|
136
|
+
for attempt in range(3):
|
|
137
|
+
try:
|
|
138
|
+
response = await acompletion(**kwargs)
|
|
139
|
+
return self._parse_response(response)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
last_error = e
|
|
142
|
+
error_str = str(e)
|
|
143
|
+
# Retry on transient / parse errors from the provider
|
|
144
|
+
is_transient = any(tok in error_str.lower() for tok in [
|
|
145
|
+
"unable to get json response",
|
|
146
|
+
"expecting value",
|
|
147
|
+
"jsondecodeerror",
|
|
148
|
+
"timeout",
|
|
149
|
+
"rate limit",
|
|
150
|
+
"429",
|
|
151
|
+
"500",
|
|
152
|
+
"502",
|
|
153
|
+
"503",
|
|
154
|
+
"504",
|
|
155
|
+
"overloaded",
|
|
156
|
+
"connection",
|
|
157
|
+
])
|
|
158
|
+
if is_transient and attempt < 2:
|
|
159
|
+
wait = 2 ** attempt # 1s, 2s
|
|
160
|
+
logger.warning(
|
|
161
|
+
f"LLM call failed (attempt {attempt + 1}/3), "
|
|
162
|
+
f"retrying in {wait}s: {error_str}"
|
|
163
|
+
)
|
|
164
|
+
import asyncio
|
|
165
|
+
await asyncio.sleep(wait)
|
|
166
|
+
continue
|
|
167
|
+
# Non-transient or final attempt — return error as content
|
|
168
|
+
return LLMResponse(
|
|
169
|
+
content=f"Error calling LLM: {error_str}",
|
|
170
|
+
finish_reason="error",
|
|
171
|
+
)
|
|
172
|
+
# Should not reach here, but just in case
|
|
173
|
+
return LLMResponse(
|
|
174
|
+
content=f"Error calling LLM: {str(last_error)}",
|
|
175
|
+
finish_reason="error",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _parse_response(self, response: Any) -> LLMResponse:
|
|
179
|
+
"""Parse LiteLLM response into our standard format."""
|
|
180
|
+
try:
|
|
181
|
+
choice = response.choices[0]
|
|
182
|
+
except (IndexError, AttributeError):
|
|
183
|
+
logger.warning("LLM response has no choices")
|
|
184
|
+
return LLMResponse(content=None, finish_reason="error")
|
|
185
|
+
|
|
186
|
+
message = choice.message
|
|
187
|
+
|
|
188
|
+
tool_calls = []
|
|
189
|
+
if hasattr(message, "tool_calls") and message.tool_calls:
|
|
190
|
+
for tc in message.tool_calls:
|
|
191
|
+
try:
|
|
192
|
+
# Parse arguments from JSON string if needed
|
|
193
|
+
args = tc.function.arguments
|
|
194
|
+
if isinstance(args, str):
|
|
195
|
+
import json
|
|
196
|
+
try:
|
|
197
|
+
args = json.loads(args)
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
args = {"raw": args}
|
|
200
|
+
|
|
201
|
+
tool_calls.append(ToolCallRequest(
|
|
202
|
+
id=tc.id or f"call_{id(tc)}",
|
|
203
|
+
name=tc.function.name,
|
|
204
|
+
arguments=args,
|
|
205
|
+
))
|
|
206
|
+
except (AttributeError, TypeError) as e:
|
|
207
|
+
logger.warning(f"Skipping malformed tool call: {e}")
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
usage = {}
|
|
211
|
+
if hasattr(response, "usage") and response.usage:
|
|
212
|
+
usage = {
|
|
213
|
+
"prompt_tokens": response.usage.prompt_tokens,
|
|
214
|
+
"completion_tokens": response.usage.completion_tokens,
|
|
215
|
+
"total_tokens": response.usage.total_tokens,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return LLMResponse(
|
|
219
|
+
content=message.content,
|
|
220
|
+
tool_calls=tool_calls,
|
|
221
|
+
finish_reason=choice.finish_reason or "stop",
|
|
222
|
+
usage=usage,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def get_default_model(self) -> str:
|
|
226
|
+
"""Get the default model."""
|
|
227
|
+
return self.default_model
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Voice transcription provider using Groq."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GroqTranscriptionProvider:
|
|
12
|
+
"""
|
|
13
|
+
Voice transcription provider using Groq's Whisper API.
|
|
14
|
+
|
|
15
|
+
Groq offers extremely fast transcription with a generous free tier.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, api_key: str | None = None):
|
|
19
|
+
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
|
20
|
+
self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions"
|
|
21
|
+
|
|
22
|
+
async def transcribe(self, file_path: str | Path) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Transcribe an audio file using Groq.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_path: Path to the audio file.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Transcribed text.
|
|
31
|
+
"""
|
|
32
|
+
if not self.api_key:
|
|
33
|
+
logger.warning("Groq API key not configured for transcription")
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
path = Path(file_path)
|
|
37
|
+
if not path.exists():
|
|
38
|
+
logger.error(f"Audio file not found: {file_path}")
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
async with httpx.AsyncClient() as client:
|
|
43
|
+
with open(path, "rb") as f:
|
|
44
|
+
files = {
|
|
45
|
+
"file": (path.name, f),
|
|
46
|
+
"model": (None, "whisper-large-v3"),
|
|
47
|
+
}
|
|
48
|
+
headers = {
|
|
49
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
response = await client.post(
|
|
53
|
+
self.api_url,
|
|
54
|
+
headers=headers,
|
|
55
|
+
files=files,
|
|
56
|
+
timeout=60.0
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
data = response.json()
|
|
61
|
+
return data.get("text", "")
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Groq transcription error: {e}")
|
|
65
|
+
return ""
|