chipp 0.3.0

Rust client for the Chipp.ai API - OpenAI-compatible chat completions with streaming support
Documentation
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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
//! Unit tests for ChippClient::chat() and chat_detailed() methods
//!
//! These tests verify the chat functionality including:
//! - Successful API calls
//! - Retry logic with exponential backoff
//! - Error handling for various failure modes
//! - Session management
//! - Token usage tracking (chat_detailed)

use chipp::{
    ChatResponse, ChippClient, ChippClientError, ChippConfig, ChippMessage, ChippSession,
    MessageRole, Usage,
};
use serde_json::json;
use std::time::Duration;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

/// Helper to create test client with mock server
async fn setup_test_client() -> (ChippClient, MockServer) {
    let mock_server = MockServer::start().await;
    let config = ChippConfig {
        api_key: "test-api-key".to_string(),
        base_url: mock_server.uri(),
        model: "test-model".to_string(),
        timeout: Duration::from_secs(5),
        max_retries: 3,
        initial_retry_delay: Duration::from_millis(10), // Fast retries for tests
        max_retry_delay: Duration::from_millis(100),
    };
    let client = ChippClient::new(config).expect("Failed to create test client");
    (client, mock_server)
}

/// Helper to create test messages
fn create_test_messages() -> Vec<ChippMessage> {
    vec![ChippMessage {
        role: MessageRole::User,
        content: "Hello".to_string(),
    }]
}

/// Helper to create successful API response with all fields
/// This matches the real Chipp API response structure
fn create_full_response(
    content: &str,
    session_id: &str,
    completion_id: &str,
    prompt_tokens: u32,
    completion_tokens: u32,
) -> serde_json::Value {
    json!({
        "chatSessionId": session_id,
        "id": completion_id,
        "object": "chat.completion",
        "created": 1234567890,
        "model": "test-model",
        "choices": [{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": content
            },
            "finish_reason": "stop"
        }],
        "usage": {
            "prompt_tokens": prompt_tokens,
            "completion_tokens": completion_tokens,
            "total_tokens": prompt_tokens + completion_tokens
        }
    })
}

/// Helper to create successful API response (backward compatible helper)
fn create_success_response(content: &str, session_id: &str) -> serde_json::Value {
    create_full_response(content, session_id, "chatcmpl-test123", 10, 5)
}

/// Tests that chat() succeeds on first attempt with valid API response
///
/// Arrange: Mock server returns successful response
/// Act: Call chat() with test message
/// Assert: Returns expected content and updates session ID
#[tokio::test]
async fn test_chat_succeeds_on_first_attempt() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .and(header("Authorization", "Bearer test-api-key"))
        .and(header("Content-Type", "application/json"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(create_success_response(
                "Hello! How can I help you?",
                "session-123",
            )),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
    assert_eq!(result.unwrap(), "Hello! How can I help you?");
    assert_eq!(session.chat_session_id, Some("session-123".to_string()));
}

/// Tests that chat() succeeds after one retry (500 then 200)
///
/// Arrange: Mock server fails once with 500, then succeeds
/// Act: Call chat() with test message
/// Assert: Returns success after retry
#[tokio::test]
async fn test_chat_succeeds_after_one_retry() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    // First attempt fails with 500
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
        .up_to_n_times(1)
        .mount(&mock_server)
        .await;

    // Second attempt succeeds
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(create_success_response("Success!", "session-456")),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(result.is_ok(), "Expected Ok after retry, got: {:?}", result);
    assert_eq!(result.unwrap(), "Success!");
    assert_eq!(session.chat_session_id, Some("session-456".to_string()));
}

/// Tests that chat() succeeds after two retries (500, 500, 200)
///
/// Arrange: Mock server fails twice with 500, then succeeds
/// Act: Call chat() with test message
/// Assert: Returns success after two retries
#[tokio::test]
async fn test_chat_succeeds_after_two_retries() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    // First two attempts fail with 500
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
        .up_to_n_times(2)
        .mount(&mock_server)
        .await;

    // Third attempt succeeds
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(create_success_response("Finally!", "session-789")),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(
        result.is_ok(),
        "Expected Ok after two retries, got: {:?}",
        result
    );
    assert_eq!(result.unwrap(), "Finally!");
    assert_eq!(session.chat_session_id, Some("session-789".to_string()));
}

/// Tests that chat() fails when max retries exceeded (all 500s)
///
/// Arrange: Mock server always returns 500
/// Act: Call chat() with test message
/// Assert: Returns MaxRetriesExceeded error
#[tokio::test]
async fn test_chat_max_retries_exceeded() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    // All attempts fail with 500
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(result.is_err(), "Expected Err, got: {:?}", result);
    match result.unwrap_err() {
        ChippClientError::MaxRetriesExceeded(max_retries) => {
            assert_eq!(max_retries, 3); // max_retries config value
        }
        other => panic!("Expected MaxRetriesExceeded, got: {:?}", other),
    }
}

/// Tests that chat() returns immediately on non-retryable 4xx error
///
/// Arrange: Mock server returns 400 Bad Request
/// Act: Call chat() with test message
/// Assert: Returns ApiError immediately without retry
#[tokio::test]
async fn test_chat_non_retryable_error_immediate_return() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
        .expect(1) // Should only be called once (no retries)
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(result.is_err(), "Expected Err, got: {:?}", result);
    match result.unwrap_err() {
        ChippClientError::ApiError { status, message } => {
            assert_eq!(status, 400);
            assert_eq!(message, "Bad Request");
        }
        other => panic!("Expected ApiError, got: {:?}", other),
    }
}

/// Tests that chat() updates session ID from API response
///
/// Arrange: Mock server returns response with new session ID
/// Act: Call chat() twice with same session
/// Assert: Session ID is updated after each call
#[tokio::test]
async fn test_chat_updates_session_id() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    // First call returns session-1
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(create_success_response("First", "session-1")),
        )
        .up_to_n_times(1)
        .mount(&mock_server)
        .await;

    // Second call returns session-2
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(create_success_response("Second", "session-2")),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act & Assert - First call
    let result1 = client.chat(&mut session, &messages).await;
    assert!(result1.is_ok());
    assert_eq!(session.chat_session_id, Some("session-1".to_string()));

    // Act & Assert - Second call
    let result2 = client.chat(&mut session, &messages).await;
    assert!(result2.is_ok());
    assert_eq!(session.chat_session_id, Some("session-2".to_string()));
}

/// Tests that chat() returns error when API returns invalid JSON
///
/// Arrange: Mock server returns 200 with invalid JSON
/// Act: Call chat() with test message
/// Assert: Returns InvalidResponse error
#[tokio::test]
async fn test_chat_invalid_json_returns_error() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(result.is_err(), "Expected Err, got: {:?}", result);
    match result.unwrap_err() {
        ChippClientError::InvalidResponse(msg) => {
            assert!(msg.contains("Failed to parse response"));
        }
        other => panic!("Expected InvalidResponse, got: {:?}", other),
    }
}

/// Tests that chat() returns error when API returns empty choices array
///
/// Arrange: Mock server returns response with no choices
/// Act: Call chat() with test message
/// Assert: Returns InvalidResponse error
#[tokio::test]
async fn test_chat_no_choices_returns_error() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    // Response with all required fields but empty choices array
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "chatSessionId": "session-123",
            "id": "chatcmpl-empty",
            "object": "chat.completion",
            "created": 1234567890,
            "model": "test-model",
            "choices": [],
            "usage": {
                "prompt_tokens": 10,
                "completion_tokens": 0,
                "total_tokens": 10
            }
        })))
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(result.is_err(), "Expected Err, got: {:?}", result);
    match result.unwrap_err() {
        ChippClientError::InvalidResponse(msg) => {
            assert!(
                msg.contains("No choices"),
                "Expected 'No choices' in error, got: {}",
                msg
            );
        }
        other => panic!("Expected InvalidResponse, got: {:?}", other),
    }
}

/// Tests that chat() returns error when API returns missing message content
///
/// Arrange: Mock server returns response with no message content
/// Act: Call chat() with test message
/// Assert: Returns InvalidResponse error
#[tokio::test]
async fn test_chat_missing_content_returns_error() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "chatSessionId": "session-123",
            "choices": [{
                "message": {}
            }]
        })))
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat(&mut session, &messages).await;

    // Assert
    assert!(result.is_err(), "Expected Err, got: {:?}", result);
    match result.unwrap_err() {
        ChippClientError::InvalidResponse(msg) => {
            // Serde error message contains "missing field `content`"
            assert!(
                msg.contains("missing field") || msg.contains("Failed to parse"),
                "Unexpected error message: {}",
                msg
            );
        }
        other => panic!("Expected InvalidResponse, got: {:?}", other),
    }
}

// =============================================================================
// chat_detailed() Tests - Token Usage and Full Response
// =============================================================================

/// Tests that chat_detailed() returns full response with token usage
///
/// Arrange: Mock server returns successful response with usage data
/// Act: Call chat_detailed() with test message
/// Assert: Returns ChatResponse with all fields populated
#[tokio::test]
async fn test_chat_detailed_returns_full_response() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .and(header("Authorization", "Bearer test-api-key"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(create_full_response(
                "Hello! I'm here to help.",
                "session-abc",
                "chatcmpl-xyz789",
                100,
                25,
            )),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat_detailed(&mut session, &messages).await;

    // Assert
    assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
    let response: ChatResponse = result.unwrap();

    // Verify content
    assert_eq!(response.content(), "Hello! I'm here to help.");

    // Verify session management
    assert_eq!(response.session_id(), "session-abc");
    assert_eq!(session.chat_session_id, Some("session-abc".to_string()));

    // Verify token usage
    assert_eq!(response.usage().prompt_tokens, 100);
    assert_eq!(response.usage().completion_tokens, 25);
    assert_eq!(response.usage().total_tokens, 125);

    // Verify metadata
    assert_eq!(response.completion_id(), "chatcmpl-xyz789");
    assert_eq!(response.created_at(), 1234567890);
    assert_eq!(response.finish_reason(), "stop");
    assert_eq!(response.model(), "test-model");
}

/// Tests that chat_detailed() tracks token usage for monitoring
///
/// Arrange: Mock server returns response with specific token counts
/// Act: Call chat_detailed()
/// Assert: Token counts are correctly captured
#[tokio::test]
async fn test_chat_detailed_token_usage_tracking() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(create_full_response(
                "Response text",
                "session-123",
                "chatcmpl-abc",
                8751,
                62,
            )),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let response = client
        .chat_detailed(&mut session, &messages)
        .await
        .expect("Should succeed");

    // Assert
    let usage: &Usage = response.usage();
    assert_eq!(usage.prompt_tokens, 8751);
    assert_eq!(usage.completion_tokens, 62);
    assert_eq!(usage.total_tokens, 8813);
}

/// Tests that chat_detailed() retries on transient failures
///
/// Arrange: Mock server fails once with 500, then succeeds
/// Act: Call chat_detailed() with test message
/// Assert: Returns success after retry with full response
#[tokio::test]
async fn test_chat_detailed_retries_on_failure() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    // First attempt fails with 500
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
        .up_to_n_times(1)
        .mount(&mock_server)
        .await;

    // Second attempt succeeds
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(create_full_response(
                "Success after retry!",
                "session-retry",
                "chatcmpl-retry456",
                50,
                10,
            )),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act
    let result = client.chat_detailed(&mut session, &messages).await;

    // Assert
    assert!(result.is_ok(), "Expected Ok after retry, got: {:?}", result);
    let response = result.unwrap();
    assert_eq!(response.content(), "Success after retry!");
    assert_eq!(response.usage().total_tokens, 60);
}

/// Tests that chat() still works and returns just content (backward compatibility)
///
/// Arrange: Mock server returns full response
/// Act: Call chat() (not chat_detailed())
/// Assert: Returns just the content string, not full response
#[tokio::test]
async fn test_chat_backward_compatibility() {
    // Arrange
    let (client, mock_server) = setup_test_client().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(create_full_response(
                "Simple response",
                "session-compat",
                "chatcmpl-compat",
                20,
                5,
            )),
        )
        .mount(&mock_server)
        .await;

    let mut session = ChippSession::new();
    let messages = create_test_messages();

    // Act - using chat() not chat_detailed()
    let result = client.chat(&mut session, &messages).await;

    // Assert - returns String, not ChatResponse
    assert!(result.is_ok());
    let content: String = result.unwrap();
    assert_eq!(content, "Simple response");

    // Session should still be updated
    assert_eq!(session.chat_session_id, Some("session-compat".to_string()));
}