nexo-tool-meta 0.1.2

Wire-shape types shared between the Nexo agent runtime and any third-party microapp that consumes its events.
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
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
//! Phase 82.10.f — `nexo/admin/llm_providers/*` wire types.
//!
//! Operates on `llm.yaml.providers.<id>`. API keys stay as
//! `${ENV_VAR}` references — the operator owns the secret; the
//! admin RPC layer never sees plaintext keys.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// One row of the `llm_providers/list` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderSummary {
    /// Provider id (matches `llm.yaml.providers.<id>` or
    /// `llm.yaml.tenants.<tenant_id>.providers.<id>`).
    pub id: String,
    /// HTTP base URL (e.g. `https://api.minimax.chat/v1`).
    pub base_url: String,
    /// Env var name holding the API key. Operator UIs render the
    /// VAR NAME (not the value); the value is read at runtime by
    /// the LLM client.
    pub api_key_env: String,
    /// Phase 83.8.12.5.c — owning tenant. `None` for the global
    /// provider table; `Some(tenant_id)` when the row lives
    /// under `llm.yaml.tenants.<tenant_id>.providers.<id>`.
    /// Operator UI uses this to badge per-tenant providers
    /// without re-querying.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_scope: Option<String>,
}

/// Phase 83.8.12.5.c — list filter shared by all
/// `llm_providers` admin RPC methods.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersListFilter {
    /// `Some(tenant_id)` returns only providers under
    /// `llm.yaml.tenants.<id>.providers`. `Some("")` is invalid
    /// (-32602). `None` returns the global table only (matches
    /// pre-Phase 83.8.12.5 behaviour). Operator-level UIs that
    /// want EVERY scope set this to `None` and merge with
    /// per-tenant lists themselves — explicit > implicit.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_id: Option<String>,
}

/// Response for `nexo/admin/llm_providers/list`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersListResponse {
    /// Providers in stable alpha order by id.
    pub providers: Vec<LlmProviderSummary>,
}

/// Params for `nexo/admin/llm_providers/upsert`.
///
/// NOT marked `#[non_exhaustive]` because operators in other
/// crates (e.g. agent-creator microapp's onboarding routes)
/// construct it with literal struct expressions + `..Default`.
/// New optional fields keep landing additively under
/// `#[serde(default)]` so wire-level back-compat is preserved.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderUpsertInput {
    /// Provider INSTANCE id. Phase 82.10.s — distinct from the
    /// factory id; can be e.g. `"minimax-cliente-a"` while
    /// `factory_type: Some("minimax")` routes against the
    /// `MiniMaxFactory`.
    pub id: String,
    /// HTTP base URL.
    pub base_url: String,
    /// LEGACY env var name holding the API key. Phase 82.10.s
    /// recommends `api_key_secret_value` instead — env vars
    /// collide between microapps in the same daemon. Kept for
    /// back-compat with pre-82.10.s yamls and the M9 wizard's
    /// existing flow.
    #[serde(default)]
    pub api_key_env: String,
    /// Optional extra HTTP headers (e.g. `X-Custom-Auth`). Empty
    /// map if not needed.
    #[serde(default)]
    pub headers: BTreeMap<String, String>,
    /// Phase 82.10.s — factory id from the catalog the daemon
    /// reports via `llm_providers/catalog`. When `Some`, the yaml
    /// instance can be named anything; when `None`, the daemon
    /// treats the instance id as the factory id (legacy path).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub factory_type: Option<String>,
    /// Phase 82.10.s — name of the secret in the daemon's
    /// SecretsStore that holds the API key. Mutually exclusive
    /// with `api_key_secret_value` AND `api_key_env`. Useful when
    /// the operator has already written the secret out-of-band
    /// (e.g. via `nexo/admin/secrets/write`) and just wants to
    /// point the provider at it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key_secret_id: Option<String>,
    /// Phase 82.10.s — write-through API key. The daemon stamps
    /// the value into the SecretsStore (under a generated id) AND
    /// sets `api_key_secret_id` on the yaml in one transaction.
    /// Audit redaction MUST mask this field — it never lands in
    /// the audit log. Mutually exclusive with `api_key_secret_id`
    /// AND `api_key_env` (loud -32602 on multi-source).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key_secret_value: Option<String>,
    /// Phase 83.8.12.5.c — when `Some(tenant_id)`, the upsert
    /// targets `llm.yaml.tenants.<tenant_id>.providers.<id>`
    /// instead of the global `providers.<id>`. `None` keeps
    /// pre-83.8.12.5 behaviour (writes the global table).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_id: Option<String>,
    /// Phase 82.10.u — selected auth mode for this instance.
    /// `None` ⇒ `AuthMode::ApiKey` (back-compat). When set to an
    /// OAuth mode, the operator must run `oauth_start` /
    /// `oauth_finish` BEFORE upsert; the resulting bundle's
    /// secret_id then lives in `fields["api_key_secret_id"]` (or
    /// equivalent — the factory's schema decides the field name).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_mode: Option<AuthMode>,
    /// Phase 82.10.u — schema-driven credential payload. Each key
    /// MUST match a `CredentialFieldDescriptor.name` from the
    /// factory's `credential_schema`. Values are validated server-
    /// side against the descriptor's `validation` + `required` +
    /// `depends_on`. `secret == true` fields are persisted to the
    /// SecretsStore + redacted in audit logs; `secret == false`
    /// fields land inline in yaml.
    ///
    /// Empty map ⇒ legacy back-compat path (handler uses
    /// `api_key_env` / `api_key_secret_id` / `api_key_secret_value`
    /// instead). Mixed mode (legacy field + non-empty `fields`) is
    /// rejected with `INVALID_FORMAT`.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub fields: BTreeMap<String, String>,
}

/// Params for `nexo/admin/llm_providers/delete`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersDeleteParams {
    /// Provider id to remove.
    pub provider_id: String,
    /// Phase 83.8.12.5.c — when `Some(tenant_id)`, the delete
    /// targets the tenant-scoped namespace. `None` removes
    /// from the global table.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_id: Option<String>,
}

/// Response for `nexo/admin/llm_providers/delete`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersDeleteResponse {
    /// `true` when the yaml block was removed. `false` when the
    /// id was already absent (idempotent).
    pub removed: bool,
}

/// Phase 82.10.u — JSON-RPC method that probes a DRAFT provider
/// payload before it lands in `llm.yaml`. Used by the SPA wizard
/// between the "fill credentials" and "pick model" steps so the
/// operator confirms the API key is accepted + enumerates live
/// models WITHOUT polluting the daemon's persisted state on a
/// failed key.
pub const LLM_PROVIDERS_PROBE_DRAFT_METHOD: &str = "nexo/admin/llm_providers/probe_draft";

/// Params for [`LLM_PROVIDERS_PROBE_DRAFT_METHOD`].
///
/// Carries the same `fields` shape as `LlmProviderUpsertInput` so
/// the SPA can reuse the form payload directly: probe first, then
/// upsert if the result is `ok`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderProbeDraftInput {
    /// Factory id from the catalog (`minimax`, `anthropic`,
    /// `openai`, etc). The handler resolves the schema + the
    /// downstream HTTP shape from this id.
    pub factory_type: String,
    /// HTTP base URL to probe. SPA pre-fills from
    /// `LlmProviderCatalogEntry::default_base_url` but operators
    /// can override (custom gateway).
    pub base_url: String,
    /// Selected auth mode. `None` ⇒ legacy api_key flow.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_mode: Option<AuthMode>,
    /// Schema-driven credential payload. Same key set as the
    /// upsert path. The handler reads `fields["api_key"]` (or the
    /// equivalent secret-flagged descriptor name) for the bearer
    /// token; `secret == false` fields (e.g. MiniMax `group_id`)
    /// are forwarded as required headers per factory.
    #[serde(default)]
    pub fields: std::collections::BTreeMap<String, String>,
}

// ──────────────────────────────────────────────────────────────────
// Phase 82.10.u — OAuth start/finish endpoints. Two-step flow:
// SPA calls `oauth_start` to get an authorize URL (auth-code) or
// user_code+verification_uri (device-code), the operator approves
// in their browser, then SPA calls `oauth_finish` with the code
// (or no code for device-code). The daemon owns the PKCE verifier
// + state across both calls via `VerifierStore` so the SPA never
// touches the verifier.
// ──────────────────────────────────────────────────────────────────

/// JSON-RPC method that initiates an OAuth flow.
pub const LLM_PROVIDERS_OAUTH_START_METHOD: &str = "nexo/admin/llm_providers/oauth_start";

/// JSON-RPC method that finalises an OAuth flow.
pub const LLM_PROVIDERS_OAUTH_FINISH_METHOD: &str = "nexo/admin/llm_providers/oauth_finish";

/// Params for [`LLM_PROVIDERS_OAUTH_START_METHOD`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthStartInput {
    /// Factory id (`anthropic`, `minimax`).
    pub factory_type: String,
    /// Auth mode chosen by the operator. Must be one of
    /// `oauth_auth_code` (Anthropic) or `oauth_device_code`
    /// (MiniMax). Other modes return `INVALID_AUTH_MODE`.
    pub auth_mode: AuthMode,
    /// Optional tenant scope for the resulting bundle. Carried
    /// across to `oauth_finish` via the verifier store.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_id: Option<String>,
}

/// Response for [`LLM_PROVIDERS_OAUTH_START_METHOD`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthStartResponse {
    /// Opaque session id the SPA carries to `oauth_finish`. Single-
    /// use: the daemon takes the entry on `oauth_finish` and a
    /// second call with the same id returns `SESSION_NOT_FOUND`.
    pub session_id: String,
    /// URL the operator opens in their browser.
    /// * For `auth_code`: the authorize URL (claude.ai/oauth/authorize?...).
    /// * For `device_code`: the verification_uri returned by the
    ///   provider (operator types `user_code` there).
    pub authorize_url: String,
    /// Unix-millis timestamp at which the session expires. SPA
    /// shows a countdown so the operator knows to retry if they
    /// take too long.
    pub expires_at_ms: i64,
    /// Discriminator: `"auth_code"` or `"device_code"`. SPA
    /// renders different UIs per kind (paste box vs spinner +
    /// user_code).
    pub flow_kind: String,
    /// Device-code-only: code the operator types into the
    /// provider's portal. `None` for auth-code flows.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub user_code: Option<String>,
    /// Device-code-only: SPA-recommended polling interval in ms
    /// (the daemon polls upstream; SPA polls the daemon at this
    /// rate to render progress). `None` for auth-code flows.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub polling_interval_ms: Option<u64>,
}

/// Params for [`LLM_PROVIDERS_OAUTH_FINISH_METHOD`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthFinishInput {
    /// Session id returned by `oauth_start`.
    pub session_id: String,
    /// LLM provider INSTANCE id under which the resulting bundle
    /// will be persisted (e.g. `"anthropic-personal"`). The yaml
    /// key, NOT the factory type.
    pub instance_id: String,
    /// `auth_code` only: the `<code>#<state>` payload the operator
    /// pasted from the callback page. Tolerates 4 input shapes
    /// (see `nexo_llm_auth::pkce::parse_code_payload`). `None` for
    /// device-code flows (the daemon polls instead).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
}

/// Response for [`LLM_PROVIDERS_OAUTH_FINISH_METHOD`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthFinishResponse {
    /// `true` when the bundle was minted + persisted.
    pub ok: bool,
    /// Operator-facing email surfaced by the provider (Anthropic
    /// returns this; MiniMax does not).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub account_email: Option<String>,
    /// Bundle access-token expiry in unix-millis.
    pub expires_at_ms: i64,
    /// Derived secret id under which the bundle JSON lives in the
    /// SecretsStore (e.g. `LLM_ANTHROPIC_PERSONAL_OAUTH_BUNDLE`).
    /// SPA uses this to populate `fields["api_key_secret_id"]` on
    /// the subsequent upsert call.
    pub secret_id: String,
}

/// Phase 82.10.l — JSON-RPC method that probes a configured LLM
/// provider's reachability + key validity from the daemon's
/// network position.
///
/// Operator UIs (e.g. M9 wizard's Step 1) call this AFTER
/// `secrets/write` + `llm_providers/upsert` to confirm the
/// daemon successfully resolved the env var AND can reach the
/// provider AND the key is accepted. Microapp's own probe
/// (`/api/onboarding/llm/probe`) only validates browser → provider;
/// this RPC closes the gap by validating daemon → provider.
pub const LLM_PROVIDERS_PROBE_METHOD: &str = "nexo/admin/llm_providers/probe";

/// Params for [`LLM_PROVIDERS_PROBE_METHOD`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderProbeInput {
    /// Provider id matching `llm.yaml.providers.<id>` (or
    /// `tenants.<tenant_id>.providers.<id>` when scoped).
    pub provider_id: String,
    /// Phase 83.8.12.5.c — tenant scope. `None` reads the global
    /// table; `Some(id)` reads the tenant namespace. v1 adapter
    /// ignores tenant scope (always reads global) — full
    /// support lands as `82.10.l.tenant`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_id: Option<String>,
}

/// Result for [`LLM_PROVIDERS_PROBE_METHOD`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderProbeResponse {
    /// `true` when the HTTP request returned 2xx.
    pub ok: bool,
    /// HTTP status from `GET {base_url}/models`. `0` for
    /// pre-request errors (DNS, connect timeout, env var unset,
    /// provider id missing).
    pub status: u16,
    /// End-to-end latency including DNS + TLS + body read.
    pub latency_ms: u64,
    /// Number of models in `data: [...]` (OpenAI-compat shape).
    /// `None` when the body isn't JSON or doesn't have `data`.
    /// Non-fatal — the probe still reports `ok: true` if HTTP
    /// status was 2xx.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_count: Option<usize>,
    /// Phase 82.10.t — model ids parsed from `data[].id` of an
    /// OpenAI-compat `/v1/models` response. `None` when the
    /// provider doesn't expose that shape (Anthropic + Gemini use
    /// distinct endpoints) or the body wasn't parseable. UI falls
    /// back to the static `models` from `llm_providers/catalog`
    /// when this is `None`. Capped at 200 entries to bound the
    /// RPC payload.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_names: Option<Vec<String>>,
    /// Sanitised error string. Never echoes the API key —
    /// every match of the key value (and its 8-char prefix) is
    /// replaced with `<redacted>` before populating this field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// JSON-RPC method that returns the static metadata for every
/// LLM provider the daemon has registered factories for. Operator
/// UIs use this to render a strict provider/model dropdown without
/// keeping their own hardcoded list in sync with the framework.
pub const LLM_PROVIDERS_CATALOG_METHOD: &str = "nexo/admin/llm_providers/catalog";

/// One row of [`LLM_PROVIDERS_CATALOG_METHOD`]'s response.
///
/// NOT marked `#[non_exhaustive]` because `src/main.rs` (and
/// downstream consumers) construct it with literal struct
/// expressions when bridging from `LlmRegistry::catalog()`. New
/// optional fields land additively under `#[serde(default)]` for
/// wire-level back-compat.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderCatalogEntry {
    /// Provider id (matches `llm.yaml.providers.<id>` and the
    /// `crates/llm/<id>.rs` factory's `name()`).
    pub id: String,
    /// Suggested HTTP base URL. Empty when the factory hasn't
    /// declared one.
    pub default_base_url: String,
    /// Conventional env var holding the API key (e.g.
    /// `MINIMAX_API_KEY`). Empty when the factory hasn't declared
    /// one.
    pub default_env_var: String,
    /// Curated list of model ids the provider's factory accepts.
    /// Empty when the factory hasn't declared any — UIs fall back
    /// to a free-text input in that case.
    pub models: Vec<String>,
    /// Phase 82.10.u — declarative credential schema. Empty for
    /// factories that haven't migrated yet (SPA falls back to the
    /// legacy "single api_key field" UI). When non-empty, the SPA
    /// renders one input per descriptor + the upsert handler
    /// validates the operator's payload against this schema.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub credential_schema: Vec<CredentialFieldDescriptor>,
    /// Phase 82.10.u — auth modes the factory supports. Empty
    /// implies `[ApiKey]` (back-compat with pre-82.10.u factories).
    /// When > 1, the SPA renders an `auth_mode` dropdown.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub supported_auth_modes: Vec<AuthMode>,
    /// Phase 82.10.u — `true` when the factory exposes an
    /// OpenAI-compat `/v1/models` endpoint that `probe_draft` can
    /// hit to enumerate live models. `false` for Anthropic +
    /// Gemini (their model lists are static and exposed via the
    /// `models` field). When `false`, the SPA skips the "validate
    /// → live models" wizard step and offers the static list
    /// directly.
    #[serde(default)]
    pub supports_models_probe: bool,
}

/// Response shape for [`LLM_PROVIDERS_CATALOG_METHOD`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersCatalogResponse {
    /// Providers in stable alpha order by id.
    pub providers: Vec<LlmProviderCatalogEntry>,
}

// ──────────────────────────────────────────────────────────────────
// Phase 82.10.u — schema-driven credential descriptors.
//
// Each LLM factory declares the credential fields it accepts. The
// admin RPC catalog surfaces the schema; the SPA wizard renders one
// input per descriptor; the upsert handler validates the operator's
// payload against the schema before touching disk.
// ──────────────────────────────────────────────────────────────────

/// Renderable credential field declared by an `LlmProviderFactory`.
///
/// `secret == true` ⇒ value is persisted to the daemon's
/// SecretsStore (mode 0600 file) and the yaml carries only an
/// `<name>_secret_id` reference. `secret == false` ⇒ value lands
/// inline in `llm.yaml.providers.<id>.<name>`.
///
/// Audit redaction MUST mask every field where `secret == true` —
/// the redactor reads this schema at runtime to decide what to
/// scrub from the audit log.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CredentialFieldDescriptor {
    /// Stable machine-readable name. Becomes the yaml key (for
    /// non-secret fields) or the secret id suffix (for secret
    /// fields). Must match `^[a-z][a-z0-9_]{1,40}$`.
    pub name: String,
    /// Operator-facing label. Free-form; the SPA may translate.
    pub label: String,
    /// Renderable input shape — see [`FieldKind`].
    pub kind: FieldKind,
    /// `true` ⇒ admin RPC rejects upsert when this field is missing
    /// (or empty after trim). Subject to [`Self::depends_on`].
    pub required: bool,
    /// `true` ⇒ value is sensitive: persisted to SecretsStore +
    /// redacted in audit logs. `false` ⇒ value is plaintext yaml.
    pub secret: bool,
    /// Default the SPA pre-fills (e.g. `"global"` for MiniMax
    /// region). `None` ⇒ no pre-fill.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
    /// Operator-facing help text — origin URL, format example, etc.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub help: Option<String>,
    /// Optional shape validation applied client-side AND server-side.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub validation: Option<FieldValidation>,
    /// Conditional visibility: this field only appears (and is only
    /// validated) when [`DependsOn::satisfied`] returns true against
    /// the upsert payload. Use to hide e.g. `setup_token` unless
    /// `auth_mode = "setup_token"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub depends_on: Option<DependsOn>,
}

/// What HTML-input shape the SPA should render for this field.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FieldKind {
    /// Plain text input.
    Text,
    /// Password input — masked by default in the SPA. Implies
    /// `secret = true` semantically (audit / persistence) but the
    /// flag remains explicit on [`CredentialFieldDescriptor`] for
    /// clarity.
    Password,
    /// `<select>` with the listed options. SPA pre-fills with the
    /// descriptor's `default` when present.
    Select {
        /// Allowed values + display labels in display order.
        options: Vec<SelectOption>,
    },
}

/// One option inside a [`FieldKind::Select`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SelectOption {
    /// Stored value (yaml-side / secret-side).
    pub value: String,
    /// Operator-facing label (i18n-ready).
    pub label: String,
}

/// Optional validation rule applied to a field's value before
/// persistence. The SPA may apply it client-side for instant
/// feedback; the admin RPC handler ALWAYS re-applies it server-side
/// so a custom client cannot smuggle invalid values.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FieldValidation {
    /// Value must match the supplied regex (anchored implicitly —
    /// callers should write `^...$` if they want full-match
    /// semantics; otherwise it's a substring check).
    Regex {
        /// Regex pattern (Rust `regex` crate syntax).
        pattern: String,
        /// Operator-facing hint shown when the regex fails.
        hint: String,
    },
    /// Value length (in unicode chars) must fall within `[min, max]`.
    Length {
        /// Minimum length (inclusive). Use 0 for unbounded below.
        min: usize,
        /// Maximum length (inclusive). Use `usize::MAX` for
        /// unbounded above.
        max: usize,
    },
}

/// Conditional-visibility predicate used by
/// [`CredentialFieldDescriptor::depends_on`].
///
/// The most common case is "this field only matters when
/// `auth_mode` equals one of these values" — see
/// [`DependsOn::any_of`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DependsOn {
    /// Field name in the upsert payload (commonly `"auth_mode"`).
    pub field: String,
    /// Field is satisfied when its value is one of these.
    pub any_of: Vec<String>,
}

impl DependsOn {
    /// Convenience constructor: this descriptor depends on
    /// `field` taking one of `values`.
    pub fn any_of(field: impl Into<String>, values: &[&str]) -> Self {
        Self {
            field: field.into(),
            any_of: values.iter().map(|s| s.to_string()).collect(),
        }
    }

    /// Evaluate against an upsert payload. Returns `true` when the
    /// dependency is satisfied (i.e. the field is present AND its
    /// value is in `any_of`). Returns `false` when the field is
    /// absent or holds a value outside the allow-list — the
    /// dependent descriptor is then hidden / skipped.
    pub fn satisfied(&self, fields: &BTreeMap<String, String>) -> bool {
        match fields.get(&self.field) {
            Some(v) => self.any_of.iter().any(|allowed| allowed == v),
            None => false,
        }
    }
}

/// Authentication mode supported by an `LlmProviderFactory`.
///
/// Each factory advertises its supported modes via
/// `LlmProviderCatalogEntry::supported_auth_modes`. The SPA shows a
/// dropdown when more than one is supported; the admin RPC handler
/// rejects upsert + oauth_start with an unsupported mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AuthMode {
    /// Static API key (the legacy default).
    #[default]
    #[serde(rename = "api_key")]
    ApiKey,
    /// Anthropic-style setup-token (`sk-ant-oat01-…`).
    #[serde(rename = "setup_token")]
    SetupToken,
    /// Authorization-code OAuth with PKCE (Anthropic Claude.ai).
    #[serde(rename = "oauth_auth_code")]
    OAuthAuthCode,
    /// Device-code OAuth user-code polling (MiniMax Token Plan).
    #[serde(rename = "oauth_device_code")]
    OAuthDeviceCode,
    /// Operator pastes a pre-existing OAuth bundle JSON.
    #[serde(rename = "oauth_bundle_import")]
    OAuthBundleImport,
}

/// Typed error surfaced by `llm_providers/upsert`,
/// `llm_providers/probe_draft`, `llm_providers/oauth_*` handlers.
///
/// Travels in `AdminRpcError::data` so the SPA can discriminate by
/// `code` and render localised messages without parsing free-form
/// strings.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "code")]
pub enum LlmProviderError {
    /// Required field absent or empty after trim.
    #[serde(rename = "MISSING_FIELD")]
    MissingField {
        /// Field name from the factory's credential schema.
        field: String,
    },
    /// Field present in payload but absent from the factory's
    /// schema. Defensive: prevents silent typo land.
    #[serde(rename = "UNKNOWN_FIELD")]
    UnknownField {
        /// The unrecognised field name.
        field: String,
    },
    /// Field present + non-empty but failed [`FieldValidation`].
    #[serde(rename = "INVALID_FORMAT")]
    InvalidFormat {
        /// Field name.
        field: String,
        /// Operator-facing hint from the descriptor.
        hint: String,
    },
    /// `auth_mode` not in the factory's `supported_auth_modes`.
    #[serde(rename = "INVALID_AUTH_MODE")]
    InvalidAuthMode {
        /// Factory id.
        factory: String,
        /// The mode the operator supplied.
        mode: String,
    },
    /// OAuth session id has elapsed its TTL.
    #[serde(rename = "SESSION_EXPIRED")]
    SessionExpired,
    /// OAuth session id was never issued or has been consumed.
    #[serde(rename = "SESSION_NOT_FOUND")]
    SessionNotFound,
    /// Upstream `oauth_finish` exchange / poll failed.
    #[serde(rename = "OAUTH_EXCHANGE_FAILED")]
    OAuthExchangeFailed {
        /// HTTP status of the upstream call (0 for transport
        /// errors).
        upstream_status: u16,
        /// Sanitised error body. NEVER contains the OAuth code or
        /// token.
        message: String,
    },
    /// `probe_draft` upstream call failed.
    #[serde(rename = "PROBE_FAILED")]
    ProbeFailed {
        /// HTTP status.
        upstream_status: u16,
        /// Sanitised error body.
        message: String,
    },
    /// Yaml patch step failed mid-upsert. Operator can retry —
    /// secret writes (if any) are idempotent under the same
    /// instance id.
    #[serde(rename = "YAML_WRITE_FAILED")]
    YamlWriteFailed {
        /// Lower-level error detail.
        detail: String,
    },
    /// SecretsStore write step failed mid-upsert.
    #[serde(rename = "SECRET_WRITE_FAILED")]
    SecretWriteFailed {
        /// Lower-level error detail.
        detail: String,
    },
}

#[cfg(test)]
mod schema_tests {
    use super::*;

    #[test]
    fn credential_field_descriptor_round_trip() {
        let d = CredentialFieldDescriptor {
            name: "api_key".into(),
            label: "API key".into(),
            kind: FieldKind::Password,
            required: true,
            secret: true,
            default: None,
            help: Some("sk-…".into()),
            validation: Some(FieldValidation::Length { min: 1, max: 200 }),
            depends_on: None,
        };
        let v = serde_json::to_value(&d).unwrap();
        let back: CredentialFieldDescriptor = serde_json::from_value(v).unwrap();
        assert_eq!(d, back);
    }

    #[test]
    fn field_kind_select_serializes_with_options() {
        let k = FieldKind::Select {
            options: vec![
                SelectOption {
                    value: "global".into(),
                    label: "Global".into(),
                },
                SelectOption {
                    value: "cn".into(),
                    label: "China".into(),
                },
            ],
        };
        let s = serde_json::to_string(&k).unwrap();
        assert!(s.contains("\"type\":\"select\""));
        assert!(s.contains("\"value\":\"global\""));
        let back: FieldKind = serde_json::from_str(&s).unwrap();
        assert_eq!(k, back);
    }

    #[test]
    fn auth_mode_wire_form_is_lowercase_oauth() {
        // Hand-written wire forms — these strings are part of the
        // public protocol contract; renaming a variant must NOT
        // change them.
        for (mode, wire) in [
            (AuthMode::ApiKey, "\"api_key\""),
            (AuthMode::SetupToken, "\"setup_token\""),
            (AuthMode::OAuthAuthCode, "\"oauth_auth_code\""),
            (AuthMode::OAuthDeviceCode, "\"oauth_device_code\""),
            (AuthMode::OAuthBundleImport, "\"oauth_bundle_import\""),
        ] {
            let s = serde_json::to_string(&mode).unwrap();
            assert_eq!(s, wire);
            let back: AuthMode = serde_json::from_str(&s).unwrap();
            assert_eq!(back, mode);
        }
    }

    #[test]
    fn llm_provider_error_round_trip_typed_data() {
        let e = LlmProviderError::MissingField {
            field: "group_id".into(),
        };
        let s = serde_json::to_string(&e).unwrap();
        assert!(s.contains("\"code\":\"MISSING_FIELD\""));
        assert!(s.contains("\"field\":\"group_id\""));
        let back: LlmProviderError = serde_json::from_str(&s).unwrap();
        assert_eq!(back, e);

        let e = LlmProviderError::OAuthExchangeFailed {
            upstream_status: 401,
            message: "invalid grant".into(),
        };
        let s = serde_json::to_string(&e).unwrap();
        assert!(s.contains("\"code\":\"OAUTH_EXCHANGE_FAILED\""));
        let back: LlmProviderError = serde_json::from_str(&s).unwrap();
        assert_eq!(back, e);
    }

    /// Phase 82.10.u back-compat: old microapps that don't know
    /// about the new `auth_mode` + `fields` keys must still be
    /// able to serialise legacy upserts. The new fields default
    /// to absent on the wire.
    #[test]
    fn upsert_input_legacy_payload_round_trips_without_new_fields() {
        let raw = r#"{"id":"minimax","base_url":"https://x","api_key_env":"K"}"#;
        let i: LlmProviderUpsertInput = serde_json::from_str(raw).unwrap();
        assert_eq!(i.id, "minimax");
        assert!(i.fields.is_empty());
        assert!(i.auth_mode.is_none());
        let back = serde_json::to_string(&i).unwrap();
        assert!(!back.contains("auth_mode"));
        assert!(!back.contains("\"fields\":"));
    }

    /// New `fields` payload survives a full round-trip and the
    /// legacy `api_key_env` stays empty when the operator opts
    /// into the schema-driven path.
    #[test]
    fn upsert_input_schema_payload_round_trip() {
        let mut fields = BTreeMap::new();
        fields.insert("api_key".into(), "sk-test".into());
        fields.insert("group_id".into(), "1234567890123".into());
        fields.insert("region".into(), "global".into());
        let i = LlmProviderUpsertInput {
            id: "minimax-cliente-a".into(),
            base_url: "https://api.minimax.io/v1".into(),
            factory_type: Some("minimax".into()),
            auth_mode: Some(AuthMode::ApiKey),
            fields,
            ..Default::default()
        };
        let s = serde_json::to_string(&i).unwrap();
        assert!(s.contains("\"auth_mode\":\"api_key\""));
        assert!(s.contains("\"fields\":{"));
        let back: LlmProviderUpsertInput = serde_json::from_str(&s).unwrap();
        assert_eq!(back.fields.len(), 3);
        assert_eq!(back.auth_mode, Some(AuthMode::ApiKey));
    }

    /// `LlmProviderCatalogEntry` legacy payloads (without
    /// `credential_schema` + `supported_auth_modes` +
    /// `supports_models_probe`) deserialise cleanly into the
    /// post-82.10.u shape so older operator UIs stay compatible.
    #[test]
    fn catalog_entry_legacy_payload_deserialises_into_82_10_u_shape() {
        let raw = r#"{
            "id": "minimax",
            "default_base_url": "https://api.minimax.io/v1",
            "default_env_var": "MINIMAX_API_KEY",
            "models": ["MiniMax-M2.5"]
        }"#;
        let e: LlmProviderCatalogEntry = serde_json::from_str(raw).unwrap();
        assert!(e.credential_schema.is_empty());
        assert!(e.supported_auth_modes.is_empty());
        assert!(!e.supports_models_probe);
    }

    #[test]
    fn depends_on_satisfied_matches_value_in_allow_list() {
        let d = DependsOn::any_of("auth_mode", &["setup_token", "api_key"]);
        let mut fields: BTreeMap<String, String> = BTreeMap::new();
        assert!(!d.satisfied(&fields), "missing key ⇒ unsatisfied");
        fields.insert("auth_mode".into(), "oauth_auth_code".into());
        assert!(
            !d.satisfied(&fields),
            "value not in allow-list ⇒ unsatisfied"
        );
        fields.insert("auth_mode".into(), "setup_token".into());
        assert!(d.satisfied(&fields), "value in allow-list ⇒ satisfied");
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn provider_summary_round_trip() {
        let s = LlmProviderSummary {
            id: "minimax".into(),
            base_url: "https://api.minimax.chat/v1".into(),
            api_key_env: "MINIMAX_API_KEY".into(),
            tenant_scope: None,
        };
        let v = serde_json::to_value(&s).unwrap();
        let back: LlmProviderSummary = serde_json::from_value(v).unwrap();
        assert_eq!(s, back);
    }

    #[test]
    fn upsert_input_default_empty_headers() {
        let i = LlmProviderUpsertInput {
            id: "minimax".into(),
            base_url: "x".into(),
            api_key_env: "Y".into(),
            ..Default::default()
        };
        let v = serde_json::to_value(&i).unwrap();
        let back: LlmProviderUpsertInput = serde_json::from_value(v).unwrap();
        assert_eq!(i, back);
    }

    /// Phase 83.8.12.5.c — `tenant_scope` round-trips when
    /// present and is omitted when `None` (graceful absence
    /// for legacy operators).
    #[test]
    fn provider_summary_tenant_scope_round_trip() {
        let with = LlmProviderSummary {
            id: "minimax".into(),
            base_url: "https://api.minimax.io".into(),
            api_key_env: "MINIMAX_KEY_ACME".into(),
            tenant_scope: Some("acme".into()),
        };
        let s = serde_json::to_string(&with).unwrap();
        assert!(s.contains("\"tenant_scope\":\"acme\""));
        let back: LlmProviderSummary = serde_json::from_str(&s).unwrap();
        assert_eq!(back, with);

        let without = LlmProviderSummary {
            id: "minimax".into(),
            base_url: "https://api.minimax.io".into(),
            api_key_env: "MINIMAX_KEY_GLOBAL".into(),
            tenant_scope: None,
        };
        let s = serde_json::to_string(&without).unwrap();
        assert!(!s.contains("tenant_scope"));
    }

    /// Phase 83.8.12.5.c — pre-Phase 83.8.12.5 microapps emit
    /// no `tenant_scope` field on summaries; deserialise must
    /// default to `None`.
    #[test]
    fn provider_summary_legacy_payload_deserialises() {
        let raw = r#"{"id":"minimax","base_url":"https://x","api_key_env":"K"}"#;
        let s: LlmProviderSummary = serde_json::from_str(raw).unwrap();
        assert!(s.tenant_scope.is_none());
    }

    #[test]
    fn upsert_input_with_tenant_id_round_trip() {
        let i = LlmProviderUpsertInput {
            id: "minimax".into(),
            base_url: "https://api.minimax.io".into(),
            api_key_env: "MINIMAX_KEY_ACME".into(),
            tenant_id: Some("acme".into()),
            ..Default::default()
        };
        let s = serde_json::to_string(&i).unwrap();
        assert!(s.contains("\"tenant_id\":\"acme\""));
        let back: LlmProviderUpsertInput = serde_json::from_str(&s).unwrap();
        assert_eq!(back, i);
    }

    #[test]
    fn delete_params_with_tenant_id_round_trip() {
        let p = LlmProvidersDeleteParams {
            provider_id: "minimax".into(),
            tenant_id: Some("acme".into()),
        };
        let s = serde_json::to_string(&p).unwrap();
        assert!(s.contains("\"tenant_id\":\"acme\""));
        let back: LlmProvidersDeleteParams = serde_json::from_str(&s).unwrap();
        assert_eq!(back, p);
    }

    #[test]
    fn list_filter_round_trip_with_and_without_tenant() {
        let with = LlmProvidersListFilter {
            tenant_id: Some("acme".into()),
        };
        let s = serde_json::to_string(&with).unwrap();
        assert_eq!(s, r#"{"tenant_id":"acme"}"#);
        let back: LlmProvidersListFilter = serde_json::from_str(&s).unwrap();
        assert_eq!(back, with);

        let without = LlmProvidersListFilter::default();
        let s = serde_json::to_string(&without).unwrap();
        assert_eq!(s, "{}", "tenant_id None must be omitted");
    }

    /// Phase 82.10.l — probe wire shapes round-trip cleanly +
    /// `tenant_id` skips when None.
    #[test]
    fn probe_input_round_trip() {
        let with = LlmProviderProbeInput {
            provider_id: "minimax".into(),
            tenant_id: Some("acme".into()),
        };
        let v = serde_json::to_value(&with).unwrap();
        let back: LlmProviderProbeInput = serde_json::from_value(v).unwrap();
        assert_eq!(back, with);

        let without = LlmProviderProbeInput {
            provider_id: "minimax".into(),
            tenant_id: None,
        };
        let s = serde_json::to_string(&without).unwrap();
        assert!(!s.contains("tenant_id"), "None tenant_id must be omitted");
    }

    #[test]
    fn probe_response_round_trip() {
        let r = LlmProviderProbeResponse {
            ok: true,
            status: 200,
            latency_ms: 142,
            model_count: Some(5),
            model_names: Some(vec!["gpt-4o".into()]),
            error: None,
        };
        let v = serde_json::to_value(&r).unwrap();
        let back: LlmProviderProbeResponse = serde_json::from_value(v).unwrap();
        assert_eq!(back, r);
    }

    #[test]
    fn probe_method_constant() {
        assert_eq!(LLM_PROVIDERS_PROBE_METHOD, "nexo/admin/llm_providers/probe");
    }
}