Skip to main content

inferd_daemon/
config.rs

1//! Daemon CLI configuration.
2//!
3//! M1 keeps the CLI surface deliberately small: one transport choice
4//! (`--tcp` or `--uds`), a lock path, a backend selector, and a queue
5//! depth. The operator-flag matrix expands in M4 along with packaging.
6
7use clap::{Parser, ValueEnum};
8use std::path::PathBuf;
9
10/// Backend adapters the daemon can register at startup.
11///
12/// `LlamaCpp` is gated behind the `llamacpp` cargo feature — default
13/// daemon builds only ship the mock adapter (per ADR 0006: lean core,
14/// extensions are separate concerns). `OpenAiCompat` is gated behind
15/// the `openai` cargo feature — pulled in only when the operator
16/// wants the outbound HTTPS adapter (ADR 0006 cloud carve-out).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
18pub enum BackendKind {
19    /// Deterministic test double — used by integration tests and the
20    /// M1 echo daemon.
21    Mock,
22    /// Local llama.cpp backend via FFI (M2). Requires `--model-path`.
23    #[cfg(feature = "llamacpp")]
24    Llamacpp,
25    /// OpenAI-compatible outbound HTTPS adapter (Phase 5A). Reaches
26    /// any provider speaking the `/v1/chat/completions` wire (OpenAI,
27    /// vLLM, LM Studio, LocalAI, OpenRouter, llama.cpp's HTTP server).
28    /// Requires `--openai-base-url` + `--openai-model`. The API key
29    /// is read from `--openai-api-key` or env (`INFERD_OPENAI_API_KEY`
30    /// then `OPENAI_API_KEY`); pass an empty string to skip the
31    /// `Authorization` header for self-hosted endpoints.
32    #[cfg(feature = "openai")]
33    OpenaiCompat,
34    /// AWS Bedrock-runtime `InvokeModelWithResponseStream` adapter
35    /// (Phase 6B-5). v0.2.0 ships only the Anthropic-on-Bedrock body
36    /// shape — Claude models invoked via Bedrock's pinned
37    /// `anthropic_version: "bedrock-2023-05-31"` payload. Requires
38    /// `--bedrock-region` + `--bedrock-model-id`. Auth resolves from
39    /// `--bedrock-bearer-token` / `AWS_BEARER_TOKEN_BEDROCK` first,
40    /// then the standard `AWS_ACCESS_KEY_ID` /
41    /// `AWS_SECRET_ACCESS_KEY` (+ optional `AWS_SESSION_TOKEN`)
42    /// chain.
43    #[cfg(feature = "bedrock")]
44    BedrockInvoke,
45}
46
47/// Top-level CLI for `inferd-daemon`.
48#[derive(Debug, Parser)]
49#[command(name = "inferd-daemon", version, about = "Local inference daemon")]
50pub struct Cli {
51    /// Backend to load at startup.
52    ///
53    /// When omitted: defer to the config file's `backends:` (or legacy
54    /// `model:` block) if one is present; otherwise fall back to the
55    /// in-memory `mock` backend so `--lock + --tcp/--uds/--pipe` alone
56    /// still boots a dev-mode echo daemon.
57    ///
58    /// When explicit: honour the CLI choice. Passing `--backend mock`
59    /// short-circuits config loading (useful for forcing mock in test
60    /// rigs even when a config file is on disk); any other explicit
61    /// kind is built from CLI flags only — config-file `backends:` are
62    /// ignored in that case so operators get exactly what they asked
63    /// for.
64    #[arg(long, value_enum, env = "INFERD_BACKEND")]
65    pub backend: Option<BackendKind>,
66
67    /// Path to the single-instance lock file. The lock is held for the
68    /// lifetime of the daemon process.
69    #[arg(long, env = "INFERD_LOCK")]
70    pub lock: PathBuf,
71
72    /// Loopback TCP bind address. Mutually exclusive with `--uds` and `--pipe`.
73    #[arg(long, env = "INFERD_TCP", conflicts_with_all = ["uds", "pipe"])]
74    pub tcp: Option<String>,
75
76    /// Unix domain socket path. Mutually exclusive with `--tcp` and `--pipe`. Unix only.
77    #[arg(long, env = "INFERD_UDS", conflicts_with_all = ["tcp", "pipe"])]
78    pub uds: Option<PathBuf>,
79
80    /// Windows named pipe path (e.g. `\\.\pipe\inferd-infer`).
81    /// Mutually exclusive with `--tcp` and `--uds`. Windows only.
82    #[arg(long, env = "INFERD_PIPE", conflicts_with_all = ["tcp", "uds"])]
83    pub pipe: Option<String>,
84
85    /// Group name for the UDS (Unix only). Ignored on other transports.
86    #[arg(long, env = "INFERD_GROUP")]
87    pub group: Option<String>,
88
89    /// Active generations served concurrently. v0.1 invariant is 1; values
90    /// above 1 are reserved for v0.2 continuous-batching backends.
91    #[arg(long, default_value_t = 1, env = "INFERD_ACTIVE_PERMITS")]
92    pub active_permits: usize,
93
94    /// Maximum waiting queue depth. Submits beyond this return
95    /// `code: queue_full` immediately.
96    #[arg(long, default_value_t = 10, env = "INFERD_QUEUE_DEPTH")]
97    pub queue_depth: usize,
98
99    /// Seconds to wait for the backend to report ready before failing
100    /// startup.
101    #[arg(long, default_value_t = 30, env = "INFERD_READY_TIMEOUT_SECS")]
102    pub ready_timeout_secs: u64,
103
104    /// Path to the GGUF model file. Required when `--backend llamacpp`.
105    #[arg(long, env = "INFERD_MODEL_PATH")]
106    pub model_path: Option<PathBuf>,
107
108    /// Optional expected SHA-256 of the model file as a hex string
109    /// (64 chars). When present, the daemon verifies the file before
110    /// loading via `subtle::ConstantTimeEq` (THREAT_MODEL F-5).
111    #[arg(long, env = "INFERD_MODEL_SHA256")]
112    pub model_sha256: Option<String>,
113
114    /// Llama.cpp context window in tokens. Default 8192.
115    #[arg(long, default_value_t = 8192, env = "INFERD_N_CTX")]
116    pub n_ctx: u32,
117
118    /// Llama.cpp GPU layer offload count. 0 = CPU-only. GPU support
119    /// requires the `cuda`/`metal`/`vulkan`/`rocm` cargo feature at
120    /// build time.
121    #[arg(long, default_value_t = 0, env = "INFERD_N_GPU_LAYERS")]
122    pub n_gpu_layers: i32,
123
124    /// Enable embed capability on the active llamacpp backend. Same
125    /// flag plumbing as `--n-ctx` / `--n-gpu-layers`: when set, this
126    /// CLI value flips `LlamacppEntry::embed = true` for both the
127    /// legacy single-model promotion path (config has `model:`) AND
128    /// the dev-mode path (no config file at all). When unset, the
129    /// effective embed flag comes from the multi-backend
130    /// `backends[].embed` field; legacy single-model and dev-mode
131    /// stay generation-only.
132    ///
133    /// Mirrors the `--embed` flag's posture: this enables the
134    /// *capability*; `--embed` separately decides whether to bind
135    /// the embed socket. Both flags are needed to actually serve
136    /// embed requests.
137    #[arg(long, env = "INFERD_LLAMACPP_EMBED")]
138    pub llamacpp_embed: bool,
139
140    /// Pooling strategy for llamacpp embeddings. Maps to llama.cpp's
141    /// `LLAMA_POOLING_TYPE_*`: 0 = NONE (per-token vectors), 1 = MEAN,
142    /// 2 = CLS, 3 = LAST. When omitted, the model's metadata pooling
143    /// default applies. Has no effect unless `--llamacpp-embed` is
144    /// also set.
145    #[arg(long, env = "INFERD_LLAMACPP_EMBED_POOLING")]
146    pub llamacpp_embed_pooling: Option<i32>,
147
148    /// Llama.cpp embed-side context window in tokens. Default 2048.
149    /// Embed requests are bounded by this; generation is unaffected.
150    /// Has no effect unless `--llamacpp-embed` is also set.
151    #[arg(long, default_value_t = 2048, env = "INFERD_LLAMACPP_EMBED_N_CTX")]
152    pub llamacpp_embed_n_ctx: u32,
153
154    /// Base URL of the upstream OpenAI-compat endpoint, no trailing
155    /// slash and no path (the adapter appends `/v1/chat/completions`).
156    /// Required when `--backend openai-compat`. Examples:
157    /// `https://api.openai.com`, `http://localhost:11434`,
158    /// `https://openrouter.ai`.
159    #[arg(long, env = "INFERD_OPENAI_BASE_URL")]
160    pub openai_base_url: Option<String>,
161
162    /// Bearer token for the OpenAI-compat upstream. Sent as
163    /// `Authorization: Bearer <value>`. Pass an empty string to skip
164    /// the header entirely for self-hosted endpoints. Resolves from
165    /// `--openai-api-key`, then `INFERD_OPENAI_API_KEY`, then
166    /// `OPENAI_API_KEY` (the de-facto env name most providers' SDKs
167    /// already use).
168    #[arg(long, env = "INFERD_OPENAI_API_KEY", hide_env_values = true)]
169    pub openai_api_key: Option<String>,
170
171    /// Upstream model identifier echoed in the request `model` field
172    /// — provider-specific (e.g. `gpt-4o-mini`, `llama3.1:8b`,
173    /// `meta-llama/Meta-Llama-3-70B-Instruct`). Required when
174    /// `--backend openai-compat`.
175    #[arg(long, env = "INFERD_OPENAI_MODEL")]
176    pub openai_model: Option<String>,
177
178    /// Total request timeout for OpenAI-compat calls, in seconds.
179    /// Default 300 (5 minutes) — long enough for a slow first-token
180    /// from a cold cloud model, short enough to surface stuck
181    /// requests rather than hang forever.
182    #[arg(long, default_value_t = 300, env = "INFERD_OPENAI_TIMEOUT_SECS")]
183    pub openai_timeout_secs: u64,
184
185    /// AWS region the Bedrock endpoint lives in, e.g. `us-east-1`,
186    /// `eu-central-1`. Required when `--backend bedrock-invoke`.
187    /// Used for both the endpoint host and SigV4 signing scope.
188    #[arg(long, env = "INFERD_BEDROCK_REGION")]
189    pub bedrock_region: Option<String>,
190
191    /// Bedrock model id (URL-encoded by the adapter), e.g.
192    /// `anthropic.claude-3-5-sonnet-20241022-v2:0`. Required when
193    /// `--backend bedrock-invoke`.
194    #[arg(long, env = "INFERD_BEDROCK_MODEL_ID")]
195    pub bedrock_model_id: Option<String>,
196
197    /// Pre-issued Bedrock bearer token (`AWS_BEARER_TOKEN_BEDROCK`
198    /// shape, AWS rolled this out in 2025-06). When set, the adapter
199    /// sends `Authorization: Bearer <value>` and skips SigV4. When
200    /// unset, the adapter falls back to the standard
201    /// `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (+ optional
202    /// `AWS_SESSION_TOKEN`) chain via SigV4 signing.
203    #[arg(long, env = "AWS_BEARER_TOKEN_BEDROCK", hide_env_values = true)]
204    pub bedrock_bearer_token: Option<String>,
205
206    /// Override the Bedrock endpoint host. Empty/absent → default
207    /// `bedrock-runtime.<region>.amazonaws.com`. Useful for VPC
208    /// endpoints / integration tests.
209    #[arg(long, env = "INFERD_BEDROCK_ENDPOINT")]
210    pub bedrock_endpoint: Option<String>,
211
212    /// Total request timeout for Bedrock calls, in seconds. Default
213    /// 300 (5 minutes).
214    #[arg(long, default_value_t = 300, env = "INFERD_BEDROCK_TIMEOUT_SECS")]
215    pub bedrock_timeout_secs: u64,
216
217    /// Optional pre-shared API key. When set, TCP clients MUST send
218    /// `{"type":"auth","key":"<this value>"}` as their first NDJSON
219    /// frame on the connection or the daemon closes the connection.
220    /// UDS and named-pipe transports ignore this — kernel-attested
221    /// peer credentials (F-7) do the work there.
222    ///
223    /// Comparison is constant-time. THREAT_MODEL F-8.
224    #[arg(long, env = "INFERD_API_KEY", hide_env_values = true)]
225    pub api_key: Option<String>,
226
227    /// Path to the operator JSON config file. Default
228    /// `~/.inferd/config.json`. When present, fetch + auto-pull are
229    /// driven from it; CLI flags (`--model-path`, `--model-sha256`,
230    /// `--n-ctx`, `--n-gpu-layers`) override config-file values when
231    /// both are supplied. When absent, the daemon falls back to
232    /// CLI-flag-only operation (dev mode).
233    #[arg(long, env = "INFERD_CONFIG")]
234    pub config: Option<PathBuf>,
235
236    /// Admin endpoint path. Defaults per-platform to the path
237    /// documented in `docs/protocol-v1.md` §"Admin endpoint" — e.g.
238    /// `/run/inferd/admin.sock` on Linux, `\\.\pipe\inferd-admin` on
239    /// Windows. Override for tests / non-default deployments.
240    #[arg(long, env = "INFERD_ADMIN_ADDR")]
241    pub admin_addr: Option<PathBuf>,
242
243    /// Enable the v2 inference endpoint per ADR 0015. v2 binds on a
244    /// *separate* socket from v1: `infer.v2.sock` on Unix /
245    /// `\\.\pipe\inferd-infer-v2` on Windows. v1 stays on its own
246    /// socket and is unaffected.
247    ///
248    /// Phase 1B: the v2 endpoint accepts and validates v2 requests
249    /// but returns `Error{code:internal, message:"v2 generation not
250    /// implemented"}` because the Backend trait does not yet expose
251    /// `generate_v2`. Use this to integration-test middleware that
252    /// will speak v2 once Phase 2A lands.
253    #[arg(long, env = "INFERD_V2")]
254    pub v2: bool,
255
256    /// Override the default v2 inference endpoint path.
257    /// Mirrors `--uds` / `--pipe` for v2; on Linux/macOS this is a
258    /// UDS path, on Windows a named-pipe path. Has no effect unless
259    /// `--v2` is also set.
260    #[arg(long, env = "INFERD_V2_ADDR")]
261    pub v2_addr: Option<PathBuf>,
262
263    /// Loopback TCP bind address for the v2 endpoint. Mutually
264    /// exclusive with `--v2-addr`. Useful for tests that don't want
265    /// the platform default (UDS / named pipe). Has no effect
266    /// unless `--v2` is also set.
267    #[arg(long, env = "INFERD_V2_TCP", conflicts_with = "v2_addr")]
268    pub v2_tcp: Option<String>,
269
270    /// Enable the embed inference endpoint per ADR 0017. The embed
271    /// endpoint binds on a *separate* socket from v1/v2:
272    /// `infer.embed.sock` on Unix / `\\.\pipe\inferd-infer-embed`
273    /// on Windows. Has no effect unless the active backend's
274    /// `capabilities().embed` is true (capability-driven binding).
275    #[arg(long, env = "INFERD_EMBED")]
276    pub embed: bool,
277
278    /// Override the default embed inference endpoint path.
279    /// Mirrors `--uds` / `--pipe` for embed; on Linux/macOS this is
280    /// a UDS path, on Windows a named-pipe path. Has no effect
281    /// unless `--embed` is also set.
282    #[arg(long, env = "INFERD_EMBED_ADDR")]
283    pub embed_addr: Option<PathBuf>,
284
285    /// Loopback TCP bind address for the embed endpoint. Mutually
286    /// exclusive with `--embed-addr`. Has no effect unless `--embed`
287    /// is also set.
288    #[arg(long, env = "INFERD_EMBED_TCP", conflicts_with = "embed_addr")]
289    pub embed_tcp: Option<String>,
290}
291
292impl Cli {
293    /// Validate that exactly one transport is selected. clap enforces
294    /// mutual exclusion; this checks the at-least-one part.
295    pub fn require_one_transport(&self) -> Result<(), &'static str> {
296        let count = [self.tcp.is_some(), self.uds.is_some(), self.pipe.is_some()]
297            .iter()
298            .filter(|b| **b)
299            .count();
300        match count {
301            1 => Ok(()),
302            0 => Err("must specify one of --tcp, --uds, --pipe"),
303            _ => Err("--tcp, --uds, --pipe are mutually exclusive"),
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use clap::CommandFactory;
312
313    #[test]
314    fn cli_parses_minimum_required() {
315        let cli = Cli::parse_from([
316            "inferd-daemon",
317            "--lock",
318            "/tmp/inferd.lock",
319            "--tcp",
320            "127.0.0.1:0",
321        ]);
322        assert!(cli.tcp.is_some());
323        assert!(cli.uds.is_none());
324        assert_eq!(cli.queue_depth, 10);
325        assert_eq!(cli.active_permits, 1);
326        cli.require_one_transport().unwrap();
327    }
328
329    #[test]
330    fn cli_rejects_no_transport() {
331        let cli = Cli::parse_from(["inferd-daemon", "--lock", "/tmp/inferd.lock"]);
332        assert!(cli.require_one_transport().is_err());
333    }
334
335    #[test]
336    fn cli_rejects_both_transports_via_clap() {
337        // clap-level mutual exclusion: this should fail to parse, not
338        // require_one_transport's runtime check.
339        let result = Cli::try_parse_from([
340            "inferd-daemon",
341            "--lock",
342            "/tmp/inferd.lock",
343            "--tcp",
344            "127.0.0.1:0",
345            "--uds",
346            "/tmp/inferd.sock",
347        ]);
348        assert!(result.is_err());
349    }
350
351    #[test]
352    fn cli_accepts_pipe_transport() {
353        let cli = Cli::parse_from([
354            "inferd-daemon",
355            "--lock",
356            "C:/tmp/inferd.lock",
357            "--pipe",
358            r"\\.\pipe\inferd-test",
359        ]);
360        assert!(cli.pipe.is_some());
361        assert!(cli.uds.is_none());
362        assert!(cli.tcp.is_none());
363        cli.require_one_transport().unwrap();
364    }
365
366    #[test]
367    fn cli_rejects_pipe_with_tcp_via_clap() {
368        let result = Cli::try_parse_from([
369            "inferd-daemon",
370            "--lock",
371            "/tmp/inferd.lock",
372            "--tcp",
373            "127.0.0.1:0",
374            "--pipe",
375            r"\\.\pipe\inferd-test",
376        ]);
377        assert!(result.is_err());
378    }
379
380    #[test]
381    fn cli_command_factory_is_well_formed() {
382        // Ensures clap's `#[command]` derives don't conflict; cheap smoke
383        // test that catches lots of misconfigurations.
384        Cli::command().debug_assert();
385    }
386
387    #[test]
388    fn cli_accepts_v2_flag() {
389        let cli = Cli::parse_from([
390            "inferd-daemon",
391            "--lock",
392            "/tmp/inferd.lock",
393            "--tcp",
394            "127.0.0.1:0",
395            "--v2",
396            "--v2-tcp",
397            "127.0.0.1:0",
398        ]);
399        assert!(cli.v2);
400        assert!(cli.v2_tcp.is_some());
401        assert!(cli.v2_addr.is_none());
402    }
403
404    #[test]
405    fn cli_rejects_v2_addr_with_v2_tcp() {
406        let result = Cli::try_parse_from([
407            "inferd-daemon",
408            "--lock",
409            "/tmp/inferd.lock",
410            "--tcp",
411            "127.0.0.1:0",
412            "--v2",
413            "--v2-tcp",
414            "127.0.0.1:0",
415            "--v2-addr",
416            "/tmp/inferd-v2.sock",
417        ]);
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn cli_v2_disabled_by_default() {
423        let cli = Cli::parse_from([
424            "inferd-daemon",
425            "--lock",
426            "/tmp/inferd.lock",
427            "--tcp",
428            "127.0.0.1:0",
429        ]);
430        assert!(!cli.v2);
431    }
432
433    #[test]
434    fn cli_accepts_embed_flag() {
435        let cli = Cli::parse_from([
436            "inferd-daemon",
437            "--lock",
438            "/tmp/inferd.lock",
439            "--tcp",
440            "127.0.0.1:0",
441            "--embed",
442            "--embed-tcp",
443            "127.0.0.1:0",
444        ]);
445        assert!(cli.embed);
446        assert!(cli.embed_tcp.is_some());
447        assert!(cli.embed_addr.is_none());
448    }
449
450    #[test]
451    fn cli_rejects_embed_addr_with_embed_tcp() {
452        let result = Cli::try_parse_from([
453            "inferd-daemon",
454            "--lock",
455            "/tmp/inferd.lock",
456            "--tcp",
457            "127.0.0.1:0",
458            "--embed",
459            "--embed-tcp",
460            "127.0.0.1:0",
461            "--embed-addr",
462            "/tmp/inferd-embed.sock",
463        ]);
464        assert!(result.is_err());
465    }
466
467    #[test]
468    fn cli_embed_disabled_by_default() {
469        let cli = Cli::parse_from([
470            "inferd-daemon",
471            "--lock",
472            "/tmp/inferd.lock",
473            "--tcp",
474            "127.0.0.1:0",
475        ]);
476        assert!(!cli.embed);
477    }
478
479    #[test]
480    fn cli_llamacpp_embed_flags_default_off() {
481        let cli = Cli::parse_from([
482            "inferd-daemon",
483            "--lock",
484            "/tmp/inferd.lock",
485            "--tcp",
486            "127.0.0.1:0",
487        ]);
488        assert!(!cli.llamacpp_embed);
489        assert!(cli.llamacpp_embed_pooling.is_none());
490        assert_eq!(cli.llamacpp_embed_n_ctx, 2048);
491    }
492
493    #[test]
494    fn cli_accepts_llamacpp_embed_flags() {
495        // Issue #16: dev-mode + legacy single-model configs need a
496        // CLI route to flip embed on without rewriting the config.
497        let cli = Cli::parse_from([
498            "inferd-daemon",
499            "--lock",
500            "/tmp/inferd.lock",
501            "--tcp",
502            "127.0.0.1:0",
503            "--llamacpp-embed",
504            "--llamacpp-embed-pooling",
505            "1",
506            "--llamacpp-embed-n-ctx",
507            "1024",
508        ]);
509        assert!(cli.llamacpp_embed);
510        assert_eq!(cli.llamacpp_embed_pooling, Some(1));
511        assert_eq!(cli.llamacpp_embed_n_ctx, 1024);
512    }
513
514    #[cfg(feature = "openai")]
515    #[test]
516    fn cli_accepts_openai_compat_backend() {
517        let cli = Cli::parse_from([
518            "inferd-daemon",
519            "--lock",
520            "/tmp/inferd.lock",
521            "--tcp",
522            "127.0.0.1:0",
523            "--backend",
524            "openai-compat",
525            "--openai-base-url",
526            "http://localhost:11434",
527            "--openai-model",
528            "llama3.1:8b",
529            "--openai-api-key",
530            "sk-x",
531            "--openai-timeout-secs",
532            "30",
533        ]);
534        assert_eq!(cli.backend, Some(BackendKind::OpenaiCompat));
535        assert_eq!(
536            cli.openai_base_url.as_deref(),
537            Some("http://localhost:11434")
538        );
539        assert_eq!(cli.openai_model.as_deref(), Some("llama3.1:8b"));
540        assert_eq!(cli.openai_api_key.as_deref(), Some("sk-x"));
541        assert_eq!(cli.openai_timeout_secs, 30);
542    }
543
544    #[cfg(feature = "bedrock")]
545    #[test]
546    fn cli_accepts_bedrock_invoke_backend() {
547        let cli = Cli::parse_from([
548            "inferd-daemon",
549            "--lock",
550            "/tmp/inferd.lock",
551            "--tcp",
552            "127.0.0.1:0",
553            "--backend",
554            "bedrock-invoke",
555            "--bedrock-region",
556            "us-east-1",
557            "--bedrock-model-id",
558            "anthropic.claude-3-5-sonnet-20241022-v2:0",
559            "--bedrock-bearer-token",
560            "abc123",
561            "--bedrock-timeout-secs",
562            "60",
563        ]);
564        assert_eq!(cli.backend, Some(BackendKind::BedrockInvoke));
565        assert_eq!(cli.bedrock_region.as_deref(), Some("us-east-1"));
566        assert_eq!(
567            cli.bedrock_model_id.as_deref(),
568            Some("anthropic.claude-3-5-sonnet-20241022-v2:0")
569        );
570        assert_eq!(cli.bedrock_bearer_token.as_deref(), Some("abc123"));
571        assert_eq!(cli.bedrock_timeout_secs, 60);
572    }
573
574    #[cfg(feature = "bedrock")]
575    #[test]
576    fn cli_bedrock_timeout_defaults_to_300() {
577        let cli = Cli::parse_from([
578            "inferd-daemon",
579            "--lock",
580            "/tmp/inferd.lock",
581            "--tcp",
582            "127.0.0.1:0",
583            "--backend",
584            "bedrock-invoke",
585            "--bedrock-region",
586            "us-east-1",
587            "--bedrock-model-id",
588            "anthropic.claude-3-5-haiku-20241022-v1:0",
589        ]);
590        assert_eq!(cli.bedrock_timeout_secs, 300);
591        assert!(cli.bedrock_bearer_token.is_none());
592        assert!(cli.bedrock_endpoint.is_none());
593    }
594
595    #[cfg(feature = "openai")]
596    #[test]
597    fn cli_openai_timeout_defaults_to_300() {
598        let cli = Cli::parse_from([
599            "inferd-daemon",
600            "--lock",
601            "/tmp/inferd.lock",
602            "--tcp",
603            "127.0.0.1:0",
604            "--backend",
605            "openai-compat",
606            "--openai-base-url",
607            "https://api.openai.com",
608            "--openai-model",
609            "gpt-4o-mini",
610        ]);
611        assert_eq!(cli.openai_timeout_secs, 300);
612        assert!(cli.openai_api_key.is_none());
613    }
614}