drep-ai 3.0.0

A local commit gate: runs the linters your repo configures, and sends changed code to an LLM for review
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
//! Unit tests for model listing.
//!
//! The response bodies here are trimmed copies of what the three subscription
//! endpoints actually returned on 2026-08-19, field sets intact. A synthetic
//! body would not reproduce the thing that matters: the three vendors agree on
//! `data[].id` and on nothing else.

use super::*;

/// z.ai, OpenAI-shaped: `object`/`created`/`owned_by`, no display name.
const ZAI: &str = r#"{"object":"list","data":[
    {"id":"glm-5.3","object":"model","created":1766332800,"owned_by":"z-ai"},
    {"id":"glm-5.2","object":"model","created":1766332800,"owned_by":"z-ai"},
    {"id":"glm-4.7","object":"model","created":1766332800,"owned_by":"z-ai"}
]}"#;

/// MiniMax, Anthropic-shaped: `type`/`created_at`/`display_name`.
const MINIMAX: &str = r#"{"data":[
    {"id":"MiniMax-M3","type":"model","display_name":"MiniMax-M3",
     "created_at":"2026-06-01T00:00:00Z"},
    {"id":"MiniMax-M2.7-highspeed","type":"model","display_name":"MiniMax-M2.7-Highspeed",
     "created_at":"2026-03-18T02:00:00Z"}
]}"#;

/// Kimi, both shapes plus capability metadata.
const KIMI: &str = r#"{"data":[
    {"id":"kimi-for-coding","created":1761264000,"created_at":"2025-10-24T00:00:00Z",
     "object":"model","display_name":"K2.7 Coding","type":"model","context_length":262144,
     "supports_reasoning":true,"supports_image_in":true,"supports_thinking_type":"only"},
    {"id":"k3","created":1761264000,"object":"model","display_name":"K3","type":"model",
     "context_length":1048576,"supports_reasoning":true}
]}"#;

fn ids(body: &str) -> Vec<String> {
    parse(body)
        .expect("parses")
        .into_iter()
        .map(|model| model.id)
        .collect()
}

#[test]
fn an_openai_shaped_listing_parses() {
    assert_eq!(ids(ZAI), vec!["glm-5.3", "glm-5.2", "glm-4.7"]);
}

#[test]
fn an_anthropic_shaped_listing_parses() {
    assert_eq!(ids(MINIMAX), vec!["MiniMax-M3", "MiniMax-M2.7-highspeed"]);
}

#[test]
fn a_listing_carrying_capability_metadata_parses() {
    // The fields drep does not read must not break the parse, or a vendor
    // adding one takes the feature out.
    assert_eq!(ids(KIMI), vec!["kimi-for-coding", "k3"]);
}

#[test]
fn a_display_name_is_kept_when_the_endpoint_sends_one() {
    let models = parse(KIMI).expect("parses");

    assert_eq!(models[0].display_name.as_deref(), Some("K2.7 Coding"));
}

#[test]
fn a_listing_without_display_names_still_parses() {
    let models = parse(ZAI).expect("parses");

    assert!(models[0].display_name.is_none());
}

#[test]
fn the_endpoints_own_order_is_preserved() {
    // Every one of these lists its newest model first, which is what a user
    // setting drep up almost always wants. Sorting would bury it: `MiniMax-M2`
    // sorts above `MiniMax-M3`, and `glm-4.7` above `glm-5.3`.
    let models = parse(ZAI).expect("parses");

    assert_eq!(models[0].id, "glm-5.3", "newest first, not alphabetical");
}

#[test]
fn a_label_shows_the_vendors_name_beside_the_id() {
    // `k3` is displayed as "K2.7 Coding" by its own vendor, which nobody would
    // guess from the id they have to put in the config.
    let model = Model {
        id: "kimi-for-coding".to_string(),
        display_name: Some("K2.7 Coding".to_string()),
    };

    assert_eq!(model.label(), "kimi-for-coding (K2.7 Coding)");
}

#[test]
fn a_label_does_not_repeat_a_display_name_equal_to_the_id() {
    // MiniMax sends `display_name` equal to `id` for every model. Rendering
    // "MiniMax-M3 (MiniMax-M3)" would be noise on the most common listing.
    let model = Model {
        id: "MiniMax-M3".to_string(),
        display_name: Some("MiniMax-M3".to_string()),
    };

    assert_eq!(model.label(), "MiniMax-M3");
}

#[test]
fn a_label_without_a_display_name_is_the_id() {
    let model = Model {
        id: "glm-5.3".to_string(),
        display_name: None,
    };

    assert_eq!(model.label(), "glm-5.3");
}

#[test]
fn an_empty_listing_is_unsupported_rather_than_an_empty_menu() {
    // A prompt offering nothing to pick is worse than the free-text prompt it
    // replaced.
    let err = parse(r#"{"data":[]}"#).expect_err("an empty list is not a listing");

    assert!(matches!(err, ListError::Unsupported), "got {err:?}");
}

#[test]
fn an_entry_with_no_id_is_dropped() {
    let models = parse(r#"{"data":[{"id":""},{"id":"real"}]}"#).expect("parses");

    assert_eq!(models.len(), 1);
    assert_eq!(models[0].id, "real");
}

#[test]
fn a_body_that_is_not_a_listing_is_malformed() {
    let err = parse("not json at all").expect_err("unparseable");

    assert!(matches!(err, ListError::Malformed(_)), "got {err:?}");
}

#[test]
fn a_json_body_with_no_data_array_is_malformed() {
    let err = parse(r#"{"models":["a"]}"#).expect_err("wrong shape");

    assert!(matches!(err, ListError::Malformed(_)), "got {err:?}");
}

#[test]
fn a_malformed_message_is_bounded_and_stripped() {
    // The same rule as every other piece of text drep did not write: it lands
    // in a terminal, so control characters cannot survive.
    let err = parse("\u{1b}[31mnot json\u{1b}[0m").expect_err("unparseable");

    let message = err.to_string();
    assert!(!message.contains('\u{1b}'), "escape survived: {message:?}");
}

#[test]
fn a_missing_route_is_unsupported_rather_than_a_failure() {
    // The ordinary case for a local server, and the reason nothing here is
    // fatal.
    for status in [404, 405, 501] {
        assert!(
            matches!(classify(status), ListError::Unsupported),
            "status {status}"
        );
    }
}

#[test]
fn a_rejected_key_is_reported_as_such() {
    // Worth separating: the user is about to store this key, and finding out it
    // is wrong now beats finding out on the first push.
    for status in [401, 403] {
        match classify(status) {
            ListError::Unauthorized(code) => assert_eq!(code, status),
            other => panic!("status {status} gave {other:?}"),
        }
    }
}

#[test]
fn any_other_status_is_a_transport_failure_naming_it() {
    match classify(500) {
        ListError::Transport(message) => assert!(message.contains("500"), "got {message}"),
        other => panic!("expected Transport, got {other:?}"),
    }
}

#[test]
fn the_listing_url_is_models_under_the_configured_base() {
    assert_eq!(
        url("https://api.z.ai/api/coding/paas/v4"),
        "https://api.z.ai/api/coding/paas/v4/models"
    );
    assert_eq!(
        url("https://api.minimax.io/anthropic/v1"),
        "https://api.minimax.io/anthropic/v1/models"
    );
}

#[test]
fn a_trailing_slash_does_not_produce_a_double_slash() {
    // Some gateways answer `//models` with a redirect and others with a 404,
    // which would report a working endpoint as having no listing.
    assert_eq!(
        url("http://localhost:1234/v1/"),
        "http://localhost:1234/v1/models"
    );
}

#[test]
fn every_error_reads_as_something_other_than_a_crash() {
    // These are shown to a user mid-setup, right before they type a name
    // instead. Each has to say which of the four things happened.
    let messages = [
        ListError::Unsupported.to_string(),
        ListError::Unauthorized(401).to_string(),
        ListError::Transport("timed out".to_string()).to_string(),
        ListError::Malformed("bad".to_string()).to_string(),
    ];

    let mut unique = messages.to_vec();
    unique.sort();
    unique.dedup();
    assert_eq!(unique.len(), 4, "messages collide: {messages:?}");
}

/// Tests for the HTTP path itself, against a mock server.
///
/// Everything above tests the parsing and classification in isolation. What
/// only these can establish is that `Http::list` wires them together: the right
/// URL, the right auth header for the protocol, and a *successful* response
/// read as a listing rather than as an error.
mod http {
    use super::*;
    use wiremock::matchers::{header, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    /// A server answering `GET /v1/models` with `body` and `status`.
    async fn server(status: u16, body: &str) -> MockServer {
        crate::test_support::json_server("/v1/models", status, body).await
    }

    #[tokio::test]
    async fn a_successful_listing_is_read_as_models() {
        // The success check itself: reading a 200 as an error would mean the
        // menu never appears for any endpoint that serves one.
        let server = server(200, ZAI).await;

        let models = Http::new()
            .list(
                &format!("{}/v1", server.uri()),
                "k",
                ApiProtocol::OpenAiChat,
            )
            .await
            .expect("a 200 listing parses");

        assert_eq!(models[0].id, "glm-5.3");
        assert_eq!(models.len(), 3);
    }

    #[tokio::test]
    async fn an_openai_endpoint_is_asked_with_a_bearer_token() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v1/models"))
            .and(header("authorization", "Bearer sk-test"))
            .respond_with(ResponseTemplate::new(200).set_body_string(ZAI.to_string()))
            .mount(&server)
            .await;

        // The mock only answers when the header matches, so parsing at all is
        // the assertion.
        Http::new()
            .list(
                &format!("{}/v1", server.uri()),
                "sk-test",
                ApiProtocol::OpenAiChat,
            )
            .await
            .expect("the bearer header was sent");
    }

    #[tokio::test]
    async fn an_anthropic_endpoint_is_asked_with_x_api_key_and_a_version() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v1/models"))
            .and(header("x-api-key", "sk-test"))
            .and(header("anthropic-version", ANTHROPIC_VERSION))
            .respond_with(ResponseTemplate::new(200).set_body_string(MINIMAX.to_string()))
            .mount(&server)
            .await;

        let models = Http::new()
            .list(
                &format!("{}/v1", server.uri()),
                "sk-test",
                ApiProtocol::Anthropic,
            )
            .await
            .expect("the anthropic headers were sent");

        assert_eq!(models[0].id, "MiniMax-M3");
    }

    #[tokio::test]
    async fn a_bearer_token_is_not_sent_to_an_anthropic_endpoint() {
        // The credential must not leak to a header the endpoint has no use for,
        // which is the same rule the completion path holds.
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v1/models"))
            .respond_with(ResponseTemplate::new(200).set_body_string(MINIMAX.to_string()))
            .mount(&server)
            .await;

        Http::new()
            .list(
                &format!("{}/v1", server.uri()),
                "sk-test",
                ApiProtocol::Anthropic,
            )
            .await
            .expect("lists");

        let requests = server.received_requests().await.expect("records requests");
        assert!(
            requests[0].headers.get("authorization").is_none(),
            "a bearer token reached an Anthropic endpoint"
        );
    }

    #[tokio::test]
    async fn a_missing_route_is_reported_as_unsupported() {
        let server = server(404, "not found").await;

        let err = Http::new()
            .list(
                &format!("{}/v1", server.uri()),
                "k",
                ApiProtocol::OpenAiChat,
            )
            .await
            .expect_err("404 is not a listing");

        assert!(matches!(err, ListError::Unsupported), "got {err:?}");
    }

    #[tokio::test]
    async fn a_rejected_key_is_reported_before_it_is_stored() {
        let server = server(401, "nope").await;

        let err = Http::new()
            .list(
                &format!("{}/v1", server.uri()),
                "wrong",
                ApiProtocol::OpenAiChat,
            )
            .await
            .expect_err("401 is not a listing");

        assert!(matches!(err, ListError::Unauthorized(401)), "got {err:?}");
    }

    #[tokio::test]
    async fn a_success_with_an_unreadable_body_is_malformed_not_unsupported() {
        // The two mean different things to the caller: one is "this endpoint has
        // no listing", the other is "it has one and drep could not read it".
        let server = server(200, "<html>nope</html>").await;

        let err = Http::new()
            .list(
                &format!("{}/v1", server.uri()),
                "k",
                ApiProtocol::OpenAiChat,
            )
            .await
            .expect_err("html is not a listing");

        assert!(matches!(err, ListError::Malformed(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn an_unreachable_endpoint_is_a_transport_failure() {
        // Port 9 refuses immediately. This is the path a local server that is
        // simply not running takes.
        let err = Http::new()
            .list("http://127.0.0.1:9/v1", "k", ApiProtocol::OpenAiChat)
            .await
            .expect_err("nothing is listening");

        assert!(matches!(err, ListError::Transport(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn an_oversized_listing_is_refused_rather_than_buffered() {
        // This endpoint is whatever the user typed at a prompt, and drep is
        // holding a key while it asks. The listing read used to be an
        // unbounded `text()` while the registry fetch beside it had a ceiling;
        // a safety property that exists in one of two places is the one that
        // gets forgotten.
        let server = server(200, ZAI).await;

        let err = Http::new()
            .with_max_bytes(4)
            .list(
                &format!("{}/v1", server.uri()),
                "k",
                ApiProtocol::OpenAiChat,
            )
            .await
            .expect_err("a listing past the ceiling is refused");

        assert!(matches!(err, ListError::Transport(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn a_key_carrying_a_newline_is_an_error_rather_than_a_crash() {
        // A key reaches this from three places and only one of them validates:
        // the wizard's prompt refuses control characters, but a key expanded
        // from `${VAR}` has been through no such check, and a pasted variable
        // routinely carries a trailing newline. Nothing in this module may stop
        // `drep init`, so an unsendable header has to arrive as a `ListError`
        // and leave the user typing a model name.
        let err = Http::new()
            .list(
                "http://127.0.0.1:9/v1",
                "sk-bad\nkey",
                ApiProtocol::OpenAiChat,
            )
            .await
            .expect_err("a newline cannot go in a header");

        assert!(matches!(err, ListError::Transport(_)), "got {err:?}");
    }

    #[test]
    fn the_production_ceiling_is_eight_megabytes() {
        // Pins the arithmetic, and that `new` is what applies it - a fetcher
        // built with a zero ceiling would refuse every real listing.
        assert_eq!(MAX_LISTING_BYTES, 8_388_608);
        assert_eq!(Http::new().max_bytes, MAX_LISTING_BYTES);
    }
}

mod redirects;