hajimi-claw 0.1.0
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.
- package/Cargo.lock +2602 -0
- package/Cargo.toml +57 -0
- package/README.md +73 -0
- package/bin/hajimi-claw.js +28 -0
- package/config.example.toml +32 -0
- package/crates/hajimi-claw-agent/Cargo.toml +25 -0
- package/crates/hajimi-claw-agent/src/lib.rs +351 -0
- package/crates/hajimi-claw-bot/Cargo.toml +18 -0
- package/crates/hajimi-claw-bot/src/lib.rs +305 -0
- package/crates/hajimi-claw-daemon/Cargo.toml +24 -0
- package/crates/hajimi-claw-daemon/src/lib.rs +173 -0
- package/crates/hajimi-claw-exec/Cargo.toml +21 -0
- package/crates/hajimi-claw-exec/src/lib.rs +419 -0
- package/crates/hajimi-claw-gateway/Cargo.toml +27 -0
- package/crates/hajimi-claw-gateway/src/lib.rs +747 -0
- package/crates/hajimi-claw-llm/Cargo.toml +19 -0
- package/crates/hajimi-claw-llm/src/lib.rs +367 -0
- package/crates/hajimi-claw-policy/Cargo.toml +14 -0
- package/crates/hajimi-claw-policy/src/lib.rs +381 -0
- package/crates/hajimi-claw-store/Cargo.toml +17 -0
- package/crates/hajimi-claw-store/src/lib.rs +730 -0
- package/crates/hajimi-claw-tools/Cargo.toml +21 -0
- package/crates/hajimi-claw-tools/src/lib.rs +758 -0
- package/crates/hajimi-claw-types/Cargo.toml +16 -0
- package/crates/hajimi-claw-types/src/lib.rs +300 -0
- package/package.json +26 -0
- package/scripts/npm-install.js +45 -0
- package/src/main.rs +4 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "hajimi-claw-llm"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
authors.workspace = true
|
|
7
|
+
|
|
8
|
+
[dependencies]
|
|
9
|
+
anyhow.workspace = true
|
|
10
|
+
async-stream.workspace = true
|
|
11
|
+
async-trait.workspace = true
|
|
12
|
+
chrono.workspace = true
|
|
13
|
+
futures.workspace = true
|
|
14
|
+
reqwest.workspace = true
|
|
15
|
+
serde.workspace = true
|
|
16
|
+
serde_json.workspace = true
|
|
17
|
+
tokio.workspace = true
|
|
18
|
+
hajimi-claw-store = { path = "../hajimi-claw-store" }
|
|
19
|
+
hajimi-claw-types = { path = "../hajimi-claw-types" }
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
use std::sync::Arc;
|
|
2
|
+
|
|
3
|
+
use async_stream::try_stream;
|
|
4
|
+
use hajimi_claw_store::Store;
|
|
5
|
+
use hajimi_claw_types::{
|
|
6
|
+
AgentEvent, AgentRequest, AgentStream, ClawError, ClawResult, ConversationMessage, LlmBackend,
|
|
7
|
+
MessageRole, ProviderConfig, ProviderHealth, ProviderKind, ProviderRecord,
|
|
8
|
+
};
|
|
9
|
+
use reqwest::Client;
|
|
10
|
+
use serde::{Deserialize, Serialize};
|
|
11
|
+
|
|
12
|
+
#[derive(Debug, Clone)]
|
|
13
|
+
pub struct OpenAiCompatibleBackend {
|
|
14
|
+
client: Client,
|
|
15
|
+
pub base_url: String,
|
|
16
|
+
pub api_key: String,
|
|
17
|
+
pub model: String,
|
|
18
|
+
pub extra_headers: Vec<(String, String)>,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl OpenAiCompatibleBackend {
|
|
22
|
+
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
|
23
|
+
Self {
|
|
24
|
+
client: Client::new(),
|
|
25
|
+
base_url,
|
|
26
|
+
api_key,
|
|
27
|
+
model,
|
|
28
|
+
extra_headers: Vec::new(),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub fn from_provider(provider: &ProviderConfig) -> Self {
|
|
33
|
+
Self {
|
|
34
|
+
client: Client::new(),
|
|
35
|
+
base_url: provider.base_url.clone(),
|
|
36
|
+
api_key: provider.api_key.clone(),
|
|
37
|
+
model: provider.model.clone(),
|
|
38
|
+
extra_headers: provider.extra_headers.clone(),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[async_trait::async_trait]
|
|
44
|
+
impl LlmBackend for OpenAiCompatibleBackend {
|
|
45
|
+
async fn respond(&self, req: AgentRequest) -> ClawResult<AgentStream> {
|
|
46
|
+
let mut builder = self
|
|
47
|
+
.client
|
|
48
|
+
.post(format!(
|
|
49
|
+
"{}/chat/completions",
|
|
50
|
+
self.base_url.trim_end_matches('/')
|
|
51
|
+
))
|
|
52
|
+
.bearer_auth(&self.api_key);
|
|
53
|
+
for (key, value) in &self.extra_headers {
|
|
54
|
+
builder = builder.header(key, value);
|
|
55
|
+
}
|
|
56
|
+
let response = builder
|
|
57
|
+
.json(&ChatCompletionRequest {
|
|
58
|
+
model: self.model.clone(),
|
|
59
|
+
messages: flatten_messages(req.system_prompt, req.messages),
|
|
60
|
+
stream: false,
|
|
61
|
+
})
|
|
62
|
+
.send()
|
|
63
|
+
.await
|
|
64
|
+
.map_err(|err| ClawError::Backend(err.to_string()))?;
|
|
65
|
+
|
|
66
|
+
if !response.status().is_success() {
|
|
67
|
+
return Err(ClawError::Backend(format!(
|
|
68
|
+
"llm request failed with status {}",
|
|
69
|
+
response.status()
|
|
70
|
+
)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let body: ChatCompletionResponse = response
|
|
74
|
+
.json()
|
|
75
|
+
.await
|
|
76
|
+
.map_err(|err| ClawError::Backend(err.to_string()))?;
|
|
77
|
+
let text = body
|
|
78
|
+
.choices
|
|
79
|
+
.first()
|
|
80
|
+
.and_then(|choice| choice.message.content.clone())
|
|
81
|
+
.unwrap_or_default();
|
|
82
|
+
|
|
83
|
+
let stream = try_stream! {
|
|
84
|
+
yield AgentEvent::TextDelta(text);
|
|
85
|
+
yield AgentEvent::Finished;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
Ok(Box::pin(stream))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#[derive(Debug, Clone)]
|
|
93
|
+
pub struct StaticBackend {
|
|
94
|
+
response: String,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
impl StaticBackend {
|
|
98
|
+
pub fn new(response: impl Into<String>) -> Self {
|
|
99
|
+
Self {
|
|
100
|
+
response: response.into(),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[async_trait::async_trait]
|
|
106
|
+
impl LlmBackend for StaticBackend {
|
|
107
|
+
async fn respond(&self, _req: AgentRequest) -> ClawResult<AgentStream> {
|
|
108
|
+
let text = self.response.clone();
|
|
109
|
+
let stream = try_stream! {
|
|
110
|
+
yield AgentEvent::TextDelta(text);
|
|
111
|
+
yield AgentEvent::Finished;
|
|
112
|
+
};
|
|
113
|
+
Ok(Box::pin(stream))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pub struct StoreBackedBackend {
|
|
118
|
+
store: Arc<Store>,
|
|
119
|
+
fallback: Option<Arc<dyn LlmBackend>>,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
impl StoreBackedBackend {
|
|
123
|
+
pub fn new(store: Arc<Store>, fallback: Option<Arc<dyn LlmBackend>>) -> Self {
|
|
124
|
+
Self { store, fallback }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[async_trait::async_trait]
|
|
129
|
+
impl LlmBackend for StoreBackedBackend {
|
|
130
|
+
async fn respond(&self, req: AgentRequest) -> ClawResult<AgentStream> {
|
|
131
|
+
let provider = resolve_provider(&self.store, req.provider_id.as_deref())
|
|
132
|
+
.map_err(|err| ClawError::Backend(err.to_string()))?;
|
|
133
|
+
|
|
134
|
+
match provider {
|
|
135
|
+
Some(record) if record.config.enabled => match record.config.kind {
|
|
136
|
+
ProviderKind::OpenAiCompatible | ProviderKind::CustomChatCompletions => {
|
|
137
|
+
OpenAiCompatibleBackend::from_provider(&record.config)
|
|
138
|
+
.respond(req)
|
|
139
|
+
.await
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
Some(_) => Err(ClawError::Backend("provider is disabled".into())),
|
|
143
|
+
None => match &self.fallback {
|
|
144
|
+
Some(fallback) => fallback.respond(req).await,
|
|
145
|
+
None => Err(ClawError::NotFound(
|
|
146
|
+
"no configured provider and no fallback backend".into(),
|
|
147
|
+
)),
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pub async fn test_provider(
|
|
154
|
+
client: &Client,
|
|
155
|
+
provider: &ProviderConfig,
|
|
156
|
+
) -> ClawResult<ProviderHealth> {
|
|
157
|
+
let suggested_models = match list_models(client, provider).await {
|
|
158
|
+
Ok(models) => models,
|
|
159
|
+
Err(ClawError::Backend(message)) => {
|
|
160
|
+
return Ok(ProviderHealth {
|
|
161
|
+
ok: false,
|
|
162
|
+
message,
|
|
163
|
+
suggested_models: vec![provider.model.clone()],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
Err(err) => return Err(err),
|
|
167
|
+
};
|
|
168
|
+
let model_ok = suggested_models.iter().any(|item| item == &provider.model);
|
|
169
|
+
|
|
170
|
+
Ok(ProviderHealth {
|
|
171
|
+
ok: true,
|
|
172
|
+
message: if model_ok {
|
|
173
|
+
format!("connected; model `{}` is available", provider.model)
|
|
174
|
+
} else if suggested_models.is_empty() {
|
|
175
|
+
format!(
|
|
176
|
+
"connected; model list unavailable, keeping `{}`",
|
|
177
|
+
provider.model
|
|
178
|
+
)
|
|
179
|
+
} else {
|
|
180
|
+
format!(
|
|
181
|
+
"connected; configured model `{}` not listed, examples: {}",
|
|
182
|
+
provider.model,
|
|
183
|
+
suggested_models.join(", ")
|
|
184
|
+
)
|
|
185
|
+
},
|
|
186
|
+
suggested_models,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pub async fn list_models(client: &Client, provider: &ProviderConfig) -> ClawResult<Vec<String>> {
|
|
191
|
+
let models_url = format!("{}/models", provider.base_url.trim_end_matches('/'));
|
|
192
|
+
let mut builder = client.get(models_url).bearer_auth(&provider.api_key);
|
|
193
|
+
for (key, value) in &provider.extra_headers {
|
|
194
|
+
builder = builder.header(key, value);
|
|
195
|
+
}
|
|
196
|
+
let response = builder
|
|
197
|
+
.send()
|
|
198
|
+
.await
|
|
199
|
+
.map_err(|err| ClawError::Backend(err.to_string()))?;
|
|
200
|
+
|
|
201
|
+
if !response.status().is_success() {
|
|
202
|
+
return Err(ClawError::Backend(format!(
|
|
203
|
+
"provider returned {}",
|
|
204
|
+
response.status()
|
|
205
|
+
)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let payload: ModelsResponse = response
|
|
209
|
+
.json()
|
|
210
|
+
.await
|
|
211
|
+
.map_err(|err| ClawError::Backend(err.to_string()))?;
|
|
212
|
+
Ok(payload
|
|
213
|
+
.data
|
|
214
|
+
.into_iter()
|
|
215
|
+
.map(|item| item.id)
|
|
216
|
+
.take(8)
|
|
217
|
+
.collect::<Vec<_>>())
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn resolve_provider(
|
|
221
|
+
store: &Store,
|
|
222
|
+
provider_id: Option<&str>,
|
|
223
|
+
) -> anyhow::Result<Option<ProviderRecord>> {
|
|
224
|
+
if let Some(provider_id) = provider_id {
|
|
225
|
+
return store.get_provider(provider_id);
|
|
226
|
+
}
|
|
227
|
+
store.get_default_provider()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#[derive(Debug, Serialize)]
|
|
231
|
+
struct ChatCompletionRequest {
|
|
232
|
+
model: String,
|
|
233
|
+
messages: Vec<ChatMessage>,
|
|
234
|
+
stream: bool,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
238
|
+
struct ChatMessage {
|
|
239
|
+
role: String,
|
|
240
|
+
content: String,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[derive(Debug, Deserialize)]
|
|
244
|
+
struct ChatCompletionResponse {
|
|
245
|
+
choices: Vec<Choice>,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#[derive(Debug, Deserialize)]
|
|
249
|
+
struct Choice {
|
|
250
|
+
message: ChatCompletionMessage,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[derive(Debug, Deserialize)]
|
|
254
|
+
struct ChatCompletionMessage {
|
|
255
|
+
content: Option<String>,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#[derive(Debug, Deserialize)]
|
|
259
|
+
struct ModelsResponse {
|
|
260
|
+
data: Vec<ModelItem>,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[derive(Debug, Deserialize)]
|
|
264
|
+
struct ModelItem {
|
|
265
|
+
id: String,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn flatten_messages(system_prompt: String, messages: Vec<ConversationMessage>) -> Vec<ChatMessage> {
|
|
269
|
+
let mut flattened = vec![ChatMessage {
|
|
270
|
+
role: "system".into(),
|
|
271
|
+
content: system_prompt,
|
|
272
|
+
}];
|
|
273
|
+
flattened.extend(messages.into_iter().map(|message| {
|
|
274
|
+
ChatMessage {
|
|
275
|
+
role: match message.role {
|
|
276
|
+
MessageRole::System => "system",
|
|
277
|
+
MessageRole::User => "user",
|
|
278
|
+
MessageRole::Assistant => "assistant",
|
|
279
|
+
MessageRole::Tool => "tool",
|
|
280
|
+
}
|
|
281
|
+
.into(),
|
|
282
|
+
content: message.content,
|
|
283
|
+
}
|
|
284
|
+
}));
|
|
285
|
+
flattened
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[cfg(test)]
|
|
289
|
+
mod tests {
|
|
290
|
+
use std::sync::Arc;
|
|
291
|
+
|
|
292
|
+
use chrono::Utc;
|
|
293
|
+
use futures::TryStreamExt;
|
|
294
|
+
use hajimi_claw_store::Store;
|
|
295
|
+
use hajimi_claw_types::{
|
|
296
|
+
AgentRequest, ConversationId, ConversationMessage, LlmBackend, MessageRole, ProviderConfig,
|
|
297
|
+
ProviderKind, ProviderRecord,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
use super::{StaticBackend, StoreBackedBackend};
|
|
301
|
+
|
|
302
|
+
#[tokio::test]
|
|
303
|
+
async fn static_backend_streams_response() {
|
|
304
|
+
let backend = StaticBackend::new("ok");
|
|
305
|
+
let stream = backend
|
|
306
|
+
.respond(AgentRequest {
|
|
307
|
+
conversation_id: ConversationId::new(),
|
|
308
|
+
provider_id: None,
|
|
309
|
+
system_prompt: "system".into(),
|
|
310
|
+
messages: vec![ConversationMessage {
|
|
311
|
+
role: MessageRole::User,
|
|
312
|
+
content: "hello".into(),
|
|
313
|
+
created_at: Utc::now(),
|
|
314
|
+
}],
|
|
315
|
+
tool_specs: vec![],
|
|
316
|
+
})
|
|
317
|
+
.await
|
|
318
|
+
.unwrap();
|
|
319
|
+
let events = stream.try_collect::<Vec<_>>().await.unwrap();
|
|
320
|
+
assert_eq!(events.len(), 2);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[tokio::test]
|
|
324
|
+
async fn store_backed_backend_uses_fallback_without_provider() {
|
|
325
|
+
let store = Arc::new(Store::open_in_memory().unwrap());
|
|
326
|
+
let backend =
|
|
327
|
+
StoreBackedBackend::new(store, Some(Arc::new(StaticBackend::new("fallback"))));
|
|
328
|
+
let stream = backend
|
|
329
|
+
.respond(AgentRequest {
|
|
330
|
+
conversation_id: ConversationId::new(),
|
|
331
|
+
provider_id: None,
|
|
332
|
+
system_prompt: "system".into(),
|
|
333
|
+
messages: vec![ConversationMessage {
|
|
334
|
+
role: MessageRole::User,
|
|
335
|
+
content: "hello".into(),
|
|
336
|
+
created_at: Utc::now(),
|
|
337
|
+
}],
|
|
338
|
+
tool_specs: vec![],
|
|
339
|
+
})
|
|
340
|
+
.await
|
|
341
|
+
.unwrap();
|
|
342
|
+
let events = stream.try_collect::<Vec<_>>().await.unwrap();
|
|
343
|
+
assert_eq!(events.len(), 2);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[test]
|
|
347
|
+
fn provider_backend_can_be_stored() {
|
|
348
|
+
let store = Store::open_in_memory().unwrap();
|
|
349
|
+
store
|
|
350
|
+
.upsert_provider(&ProviderRecord {
|
|
351
|
+
config: ProviderConfig {
|
|
352
|
+
id: "demo".into(),
|
|
353
|
+
label: "Demo".into(),
|
|
354
|
+
kind: ProviderKind::OpenAiCompatible,
|
|
355
|
+
base_url: "https://example.com/v1".into(),
|
|
356
|
+
api_key: "secret".into(),
|
|
357
|
+
model: "gpt-demo".into(),
|
|
358
|
+
enabled: true,
|
|
359
|
+
extra_headers: vec![],
|
|
360
|
+
created_at: Utc::now(),
|
|
361
|
+
},
|
|
362
|
+
is_default: true,
|
|
363
|
+
})
|
|
364
|
+
.unwrap();
|
|
365
|
+
assert!(store.get_default_provider().unwrap().is_some());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "hajimi-claw-policy"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
authors.workspace = true
|
|
7
|
+
|
|
8
|
+
[dependencies]
|
|
9
|
+
chrono.workspace = true
|
|
10
|
+
hajimi-claw-types = { path = "../hajimi-claw-types" }
|
|
11
|
+
regex.workspace = true
|
|
12
|
+
serde.workspace = true
|
|
13
|
+
uuid.workspace = true
|
|
14
|
+
|