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.
@@ -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
+