Skip to main content

everruns_core/
model_router.rs

1// Model Router domain types
2//
3// Design intent lives in `specs/model-router.md`.
4//
5// A Model Router is an org-scoped, named container of named routes. Each
6// route picks a concrete LLM model via a strategy and a list of candidates;
7// candidates carry provider-agnostic request overrides (reasoning_effort,
8// temperature, etc.). Harnesses, agents, sessions, and org settings can bind
9// to either a concrete model (today's behavior, preserved) or to a router.
10//
11// This module defines the entity, the route, the candidate, the strategy
12// enum, and structural validation. Storage trait, REST APIs, runtime
13// resolver, binding migrations, and UI ship as follow-up vertical slices.
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19use crate::driver_registry::OpenRouterRoutingConfig;
20use crate::typed_id::{ModelId, ModelRouterId};
21
22#[cfg(feature = "openapi")]
23use utoipa::ToSchema;
24
25/// Router lifecycle status. Mirrors other building-block lifecycles.
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(ToSchema))]
28#[serde(rename_all = "lowercase")]
29pub enum ModelRouterStatus {
30    Active,
31    Archived,
32    Deleted,
33}
34
35impl std::fmt::Display for ModelRouterStatus {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            ModelRouterStatus::Active => write!(f, "active"),
39            ModelRouterStatus::Archived => write!(f, "archived"),
40            ModelRouterStatus::Deleted => write!(f, "deleted"),
41        }
42    }
43}
44
45impl From<&str> for ModelRouterStatus {
46    fn from(s: &str) -> Self {
47        match s {
48            "archived" => ModelRouterStatus::Archived,
49            "deleted" => ModelRouterStatus::Deleted,
50            _ => ModelRouterStatus::Active,
51        }
52    }
53}
54
55/// Selection strategy for a route. See `specs/model-router.md` for behavior.
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[cfg_attr(feature = "openapi", derive(ToSchema))]
58#[serde(rename_all = "snake_case")]
59pub enum ModelRouterStrategy {
60    /// Exactly one candidate; trivial selection.
61    Single,
62    /// Try candidates in `position` order; fall through to the next on
63    /// transient errors.
64    OrderedFallback,
65    /// Sample a candidate by `weight` per call.
66    Weighted,
67    /// Evaluate candidate `rules` against binding `params`; first match wins.
68    Rules,
69    /// Hand off to an embedded resolver registered by the host runtime.
70    /// Database stores the candidate list as advisory metadata.
71    Custom,
72}
73
74impl std::fmt::Display for ModelRouterStrategy {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            ModelRouterStrategy::Single => write!(f, "single"),
78            ModelRouterStrategy::OrderedFallback => write!(f, "ordered_fallback"),
79            ModelRouterStrategy::Weighted => write!(f, "weighted"),
80            ModelRouterStrategy::Rules => write!(f, "rules"),
81            ModelRouterStrategy::Custom => write!(f, "custom"),
82        }
83    }
84}
85
86impl ModelRouterStrategy {
87    /// Parse from the canonical string form (matches the DB CHECK constraint
88    /// on `model_router_routes.strategy`).
89    pub fn parse(s: &str) -> Result<Self, String> {
90        match s {
91            "single" => Ok(ModelRouterStrategy::Single),
92            "ordered_fallback" => Ok(ModelRouterStrategy::OrderedFallback),
93            "weighted" => Ok(ModelRouterStrategy::Weighted),
94            "rules" => Ok(ModelRouterStrategy::Rules),
95            "custom" => Ok(ModelRouterStrategy::Custom),
96            other => Err(format!(
97                "unknown model router strategy '{other}'; expected one of single, ordered_fallback, weighted, rules, custom"
98            )),
99        }
100    }
101}
102
103/// A Model Router — org-scoped named container of routes.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "openapi", derive(ToSchema))]
106pub struct ModelRouter {
107    /// External identifier (`mrtr_<32-hex>`). Shown as `id` in API responses.
108    #[serde(rename = "id")]
109    #[cfg_attr(
110        feature = "openapi",
111        schema(value_type = String, example = "mrtr_01933b5a000070008000000000000001")
112    )]
113    pub public_id: ModelRouterId,
114    /// Internal UUID primary key. Used for FK references. Never exposed in API.
115    #[serde(skip, default = "Uuid::nil")]
116    pub internal_id: Uuid,
117    /// Human-readable name, unique per org while not deleted.
118    pub name: String,
119    /// Optional description for the UI.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub description: Option<String>,
122    /// JSON Schema describing caller-supplied params validated at binding
123    /// time. Defaults to an empty object — `{}` means no parameters expected
124    /// — never JSON `null`, so wire and DB shapes stay consistent.
125    #[serde(default = "default_empty_object")]
126    pub param_schema: serde_json::Value,
127    pub status: ModelRouterStatus,
128    pub created_at: DateTime<Utc>,
129    pub updated_at: DateTime<Utc>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub archived_at: Option<DateTime<Utc>>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub deleted_at: Option<DateTime<Utc>>,
134    /// Routes belonging to this router. Populated by service-layer joins.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub routes: Vec<ModelRouterRoute>,
137}
138
139/// A named route inside a router. Carries the human-facing `purpose` and
140/// the model-facing `when_to_use` description used by the future
141/// `set_model` discoverability tool.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[cfg_attr(feature = "openapi", derive(ToSchema))]
144pub struct ModelRouterRoute {
145    pub id: Uuid,
146    /// Stable identifier within router (e.g. `base`, `analysis`).
147    pub key: String,
148    /// Human-facing label.
149    pub purpose: String,
150    /// Model-facing description (used by future `set_model` tool).
151    pub when_to_use: String,
152    pub strategy: ModelRouterStrategy,
153    /// Display order within router.
154    pub position: i32,
155    /// Candidates inside this route, in position order.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub candidates: Vec<ModelRouterCandidate>,
158    pub created_at: DateTime<Utc>,
159    pub updated_at: DateTime<Utc>,
160}
161
162/// An ordered candidate inside a route. References a concrete model and may
163/// carry provider-agnostic request overrides plus a weight (for `weighted`)
164/// or rules (for `rules`).
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "openapi", derive(ToSchema))]
167pub struct ModelRouterCandidate {
168    pub id: Uuid,
169    /// The concrete model to invoke.
170    #[cfg_attr(
171        feature = "openapi",
172        schema(value_type = String, example = "model_01933b5a000070008000000000000001")
173    )]
174    pub model_id: ModelId,
175    /// Provider-agnostic overrides applied at LLM-call time
176    /// (`reasoning_effort`, `temperature`, `max_output_tokens`, ...).
177    /// Defaults to an empty object so missing fields stay object-shaped on
178    /// the wire, matching the DB JSONB default of `{}`.
179    #[serde(default = "default_empty_object")]
180    pub request_overrides: serde_json::Value,
181    /// Used by `weighted` strategy. Defaults to `1` (uniform).
182    #[serde(default = "default_weight")]
183    pub weight: i32,
184    /// Used by `rules` strategy. None for other strategies.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub rules: Option<serde_json::Value>,
187    /// Used by `ordered_fallback` strategy.
188    pub position: i32,
189    pub created_at: DateTime<Utc>,
190    pub updated_at: DateTime<Utc>,
191}
192
193fn default_weight() -> i32 {
194    1
195}
196
197fn default_empty_object() -> serde_json::Value {
198    serde_json::Value::Object(serde_json::Map::new())
199}
200
201/// Maximum length for a route key (matches the DB column).
202pub const MAX_ROUTE_KEY_LEN: usize = 64;
203
204/// Validate a route `key` (lowercase letters, digits, and hyphens; no
205/// leading/trailing hyphen; max 64 chars). Mirrors the DB CHECK constraint
206/// on `model_router_routes.key`.
207pub fn validate_route_key(key: &str) -> Result<(), String> {
208    if key.is_empty() {
209        return Err("route key must not be empty".into());
210    }
211    if key.len() > MAX_ROUTE_KEY_LEN {
212        return Err(format!(
213            "route key must be at most {MAX_ROUTE_KEY_LEN} characters"
214        ));
215    }
216    if !key
217        .bytes()
218        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
219    {
220        return Err("route key must contain only lowercase letters, digits, and hyphens".into());
221    }
222    if key.starts_with('-') || key.ends_with('-') {
223        return Err("route key must not start or end with a hyphen".into());
224    }
225    Ok(())
226}
227
228/// Validate a candidate's structural shape (without DB access). Domain-level
229/// cross-validation (model belongs to caller's org, route exists, etc.) is
230/// performed at the server layer.
231pub fn validate_candidate_shape(
232    candidate: &ModelRouterCandidate,
233    strategy: ModelRouterStrategy,
234) -> Result<(), String> {
235    if candidate.weight < 0 {
236        return Err(format!(
237            "candidate.weight must be non-negative, got {}",
238            candidate.weight
239        ));
240    }
241    match strategy {
242        ModelRouterStrategy::Rules => {
243            if candidate.rules.is_none() {
244                return Err(
245                    "candidates under a 'rules' strategy must have a rules document set"
246                        .to_string(),
247                );
248            }
249        }
250        ModelRouterStrategy::Single
251        | ModelRouterStrategy::OrderedFallback
252        | ModelRouterStrategy::Weighted
253        | ModelRouterStrategy::Custom => {
254            // No strategy-specific structural requirement on the candidate beyond weight.
255        }
256    }
257    Ok(())
258}
259
260/// Validate a route's full shape (key + strategy + candidate count rules).
261/// Cross-row uniqueness (route `key` per router, candidate ordering) is
262/// enforced at the storage layer via unique indexes.
263pub fn validate_route_shape(route: &ModelRouterRoute) -> Result<(), String> {
264    validate_route_key(&route.key)?;
265    if matches!(route.strategy, ModelRouterStrategy::Single) && route.candidates.len() != 1 {
266        return Err(format!(
267            "route '{}' has strategy 'single' but {} candidates; single-strategy routes must have exactly one candidate",
268            route.key,
269            route.candidates.len()
270        ));
271    }
272    for candidate in &route.candidates {
273        validate_candidate_shape(candidate, route.strategy)?;
274    }
275    Ok(())
276}
277
278/// OpenRouter-ready routing plan compiled from a model-router route.
279#[derive(Debug, Clone, PartialEq)]
280pub struct OpenRouterRoutePlan {
281    /// The concrete model slug to place in the required `model` request field.
282    pub primary_model: String,
283    /// Optional OpenRouter fallback routing fields. `None` means the route is a
284    /// direct single-model invocation and no provider-specific fields are needed.
285    pub routing: Option<OpenRouterRoutingConfig>,
286}
287
288/// Compile the currently executable Model Router strategies into OpenRouter's
289/// request-level fallback routing fields.
290///
291/// `model_slug_for_candidate` resolves Everruns `ModelId` references to the
292/// OpenRouter model slugs used on the wire (for example
293/// `anthropic/claude-sonnet-4.5`). Storage-backed resolution lives outside this
294/// foundational router module, so the caller supplies the lookup.
295pub fn compile_openrouter_route_plan(
296    route: &ModelRouterRoute,
297    model_slug_for_candidate: impl Fn(&ModelRouterCandidate) -> Option<String>,
298) -> Result<OpenRouterRoutePlan, String> {
299    validate_route_shape(route)?;
300
301    match route.strategy {
302        ModelRouterStrategy::Single => {
303            let candidate = route
304                .candidates
305                .first()
306                .ok_or_else(|| format!("route '{}' has no candidates", route.key))?;
307            let primary_model = model_slug_for_candidate(candidate).ok_or_else(|| {
308                format!(
309                    "route '{}' candidate '{}' does not resolve to an OpenRouter model slug",
310                    route.key, candidate.id
311                )
312            })?;
313            Ok(OpenRouterRoutePlan {
314                primary_model,
315                routing: None,
316            })
317        }
318        ModelRouterStrategy::OrderedFallback => {
319            let mut candidates = route.candidates.iter().collect::<Vec<_>>();
320            candidates.sort_by_key(|candidate| candidate.position);
321
322            let mut models = Vec::with_capacity(candidates.len());
323            for candidate in candidates {
324                let slug = model_slug_for_candidate(candidate).ok_or_else(|| {
325                    format!(
326                        "route '{}' candidate '{}' does not resolve to an OpenRouter model slug",
327                        route.key, candidate.id
328                    )
329                })?;
330                models.push(slug);
331            }
332
333            let primary_model = models
334                .first()
335                .cloned()
336                .ok_or_else(|| format!("route '{}' has no candidates", route.key))?;
337            Ok(OpenRouterRoutePlan {
338                primary_model,
339                routing: Some(OpenRouterRoutingConfig::fallback_models(models)),
340            })
341        }
342        ModelRouterStrategy::Weighted
343        | ModelRouterStrategy::Rules
344        | ModelRouterStrategy::Custom => Err(format!(
345            "route '{}' strategy '{}' cannot be compiled directly to OpenRouter fallback routing",
346            route.key, route.strategy
347        )),
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn now() -> DateTime<Utc> {
356        DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap()
357    }
358
359    fn candidate(weight: i32, rules: Option<serde_json::Value>) -> ModelRouterCandidate {
360        candidate_with(weight, rules, 0, 1)
361    }
362
363    fn candidate_with(
364        weight: i32,
365        rules: Option<serde_json::Value>,
366        position: i32,
367        model_seed: u128,
368    ) -> ModelRouterCandidate {
369        ModelRouterCandidate {
370            id: Uuid::from_u128(model_seed),
371            model_id: ModelId::from_seed(model_seed),
372            request_overrides: serde_json::Value::Null,
373            weight,
374            rules,
375            position,
376            created_at: now(),
377            updated_at: now(),
378        }
379    }
380
381    fn route(
382        strategy: ModelRouterStrategy,
383        candidates: Vec<ModelRouterCandidate>,
384    ) -> ModelRouterRoute {
385        ModelRouterRoute {
386            id: Uuid::nil(),
387            key: "base".into(),
388            purpose: "default route".into(),
389            when_to_use: "use this when no specific route fits".into(),
390            strategy,
391            position: 0,
392            candidates,
393            created_at: now(),
394            updated_at: now(),
395        }
396    }
397
398    #[test]
399    fn status_round_trip() {
400        assert_eq!(ModelRouterStatus::from("active").to_string(), "active");
401        assert_eq!(ModelRouterStatus::from("archived").to_string(), "archived");
402        assert_eq!(ModelRouterStatus::from("deleted").to_string(), "deleted");
403        assert_eq!(ModelRouterStatus::from("unknown").to_string(), "active");
404    }
405
406    #[test]
407    fn strategy_parse_round_trip() {
408        for s in ["single", "ordered_fallback", "weighted", "rules", "custom"] {
409            assert_eq!(ModelRouterStrategy::parse(s).unwrap().to_string(), s);
410        }
411    }
412
413    #[test]
414    fn strategy_parse_rejects_unknown() {
415        let err = ModelRouterStrategy::parse("invalid").unwrap_err();
416        assert!(err.contains("unknown model router strategy"));
417    }
418
419    #[test]
420    fn route_key_accepts_canonical_keys() {
421        for key in ["base", "utility", "analysis", "review", "fast-path", "v1"] {
422            assert!(validate_route_key(key).is_ok(), "should accept key '{key}'");
423        }
424    }
425
426    #[test]
427    fn route_key_rejects_empty() {
428        assert!(validate_route_key("").is_err());
429    }
430
431    #[test]
432    fn route_key_rejects_uppercase() {
433        assert!(validate_route_key("Analysis").is_err());
434    }
435
436    #[test]
437    fn route_key_rejects_underscore() {
438        assert!(validate_route_key("fast_path").is_err());
439    }
440
441    #[test]
442    fn route_key_rejects_leading_hyphen() {
443        assert!(validate_route_key("-fast").is_err());
444    }
445
446    #[test]
447    fn route_key_rejects_trailing_hyphen() {
448        assert!(validate_route_key("fast-").is_err());
449    }
450
451    #[test]
452    fn route_key_rejects_too_long() {
453        let key = "a".repeat(MAX_ROUTE_KEY_LEN + 1);
454        assert!(validate_route_key(&key).is_err());
455    }
456
457    #[test]
458    fn candidate_shape_rejects_negative_weight() {
459        let cand = candidate(-1, None);
460        assert!(validate_candidate_shape(&cand, ModelRouterStrategy::Weighted).is_err());
461    }
462
463    #[test]
464    fn candidate_shape_rules_strategy_requires_rules_doc() {
465        let cand = candidate(1, None);
466        let err = validate_candidate_shape(&cand, ModelRouterStrategy::Rules).unwrap_err();
467        assert!(err.contains("rules"));
468    }
469
470    #[test]
471    fn candidate_shape_rules_strategy_accepts_rules_doc() {
472        let cand = candidate(1, Some(serde_json::json!({ "if": { "tier": "fast" } })));
473        assert!(validate_candidate_shape(&cand, ModelRouterStrategy::Rules).is_ok());
474    }
475
476    #[test]
477    fn route_shape_rejects_single_with_multiple_candidates() {
478        let route = ModelRouterRoute {
479            id: Uuid::nil(),
480            key: "base".into(),
481            purpose: "default route".into(),
482            when_to_use: "use this when no specific route fits".into(),
483            strategy: ModelRouterStrategy::Single,
484            position: 0,
485            candidates: vec![candidate(1, None), candidate(1, None)],
486            created_at: now(),
487            updated_at: now(),
488        };
489        let err = validate_route_shape(&route).unwrap_err();
490        assert!(err.contains("single"));
491    }
492
493    #[test]
494    fn route_shape_rejects_single_with_zero_candidates() {
495        let route = ModelRouterRoute {
496            id: Uuid::nil(),
497            key: "base".into(),
498            purpose: "default route".into(),
499            when_to_use: "use this when no specific route fits".into(),
500            strategy: ModelRouterStrategy::Single,
501            position: 0,
502            candidates: vec![],
503            created_at: now(),
504            updated_at: now(),
505        };
506        let err = validate_route_shape(&route).unwrap_err();
507        assert!(err.contains("single"));
508    }
509
510    #[test]
511    fn route_shape_accepts_single_with_exactly_one_candidate() {
512        let route = ModelRouterRoute {
513            id: Uuid::nil(),
514            key: "base".into(),
515            purpose: "default route".into(),
516            when_to_use: "use this when no specific route fits".into(),
517            strategy: ModelRouterStrategy::Single,
518            position: 0,
519            candidates: vec![candidate(1, None)],
520            created_at: now(),
521            updated_at: now(),
522        };
523        assert!(validate_route_shape(&route).is_ok());
524    }
525
526    #[test]
527    fn route_shape_accepts_ordered_fallback_with_multiple_candidates() {
528        let route = route(
529            ModelRouterStrategy::OrderedFallback,
530            vec![candidate(1, None), candidate(1, None)],
531        );
532        assert!(validate_route_shape(&route).is_ok());
533    }
534
535    #[test]
536    fn openrouter_plan_single_returns_primary_without_routing() {
537        let route = route(ModelRouterStrategy::Single, vec![candidate(1, None)]);
538
539        let plan = compile_openrouter_route_plan(&route, |candidate| {
540            assert_eq!(candidate.model_id, ModelId::from_seed(1));
541            Some("openai/gpt-5-mini".to_string())
542        })
543        .unwrap();
544
545        assert_eq!(plan.primary_model, "openai/gpt-5-mini");
546        assert_eq!(plan.routing, None);
547    }
548
549    #[test]
550    fn openrouter_plan_ordered_fallback_preserves_candidate_order() {
551        let route = route(
552            ModelRouterStrategy::OrderedFallback,
553            vec![
554                candidate_with(1, None, 10, 2),
555                candidate_with(1, None, 0, 1),
556            ],
557        );
558
559        let plan = compile_openrouter_route_plan(&route, |candidate| {
560            if candidate.model_id == ModelId::from_seed(1) {
561                Some("openai/gpt-5-mini".to_string())
562            } else if candidate.model_id == ModelId::from_seed(2) {
563                Some("anthropic/claude-sonnet-4.5".to_string())
564            } else {
565                None
566            }
567        })
568        .unwrap();
569
570        assert_eq!(plan.primary_model, "openai/gpt-5-mini");
571        let routing = plan.routing.unwrap();
572        assert_eq!(
573            routing.models,
574            vec![
575                "openai/gpt-5-mini".to_string(),
576                "anthropic/claude-sonnet-4.5".to_string(),
577            ]
578        );
579        assert_eq!(
580            routing.route,
581            Some(crate::driver_registry::OpenRouterRoute::Fallback)
582        );
583    }
584
585    #[test]
586    fn openrouter_plan_rejects_uncompiled_strategies() {
587        let route = route(ModelRouterStrategy::Weighted, vec![candidate(1, None)]);
588
589        let err = compile_openrouter_route_plan(&route, |_| Some("openai/gpt-5-mini".to_string()))
590            .unwrap_err();
591
592        assert!(err.contains("cannot be compiled directly"));
593    }
594
595    #[test]
596    fn openrouter_plan_rejects_missing_model_slug() {
597        let route = route(ModelRouterStrategy::Single, vec![candidate(1, None)]);
598
599        let err = compile_openrouter_route_plan(&route, |_| None).unwrap_err();
600
601        assert!(err.contains("does not resolve to an OpenRouter model slug"));
602    }
603
604    #[test]
605    fn candidate_default_weight_is_one() {
606        let json = r#"{
607            "id": "00000000-0000-0000-0000-000000000000",
608            "model_id": "model_00000000000000000000000000000001",
609            "position": 0,
610            "created_at": "2024-01-01T00:00:00Z",
611            "updated_at": "2024-01-01T00:00:00Z"
612        }"#;
613        let cand: ModelRouterCandidate = serde_json::from_str(json).unwrap();
614        assert_eq!(cand.weight, 1);
615    }
616
617    #[test]
618    fn candidate_default_request_overrides_is_empty_object() {
619        // Wire and DB shapes must match: missing `request_overrides` defaults
620        // to `{}`, never JSON `null`, so downstream consumers can rely on an
621        // object-shaped value.
622        let json = r#"{
623            "id": "00000000-0000-0000-0000-000000000000",
624            "model_id": "model_00000000000000000000000000000001",
625            "position": 0,
626            "created_at": "2024-01-01T00:00:00Z",
627            "updated_at": "2024-01-01T00:00:00Z"
628        }"#;
629        let cand: ModelRouterCandidate = serde_json::from_str(json).unwrap();
630        assert!(
631            cand.request_overrides.is_object(),
632            "expected default request_overrides to be a JSON object, got {:?}",
633            cand.request_overrides
634        );
635        assert_eq!(cand.request_overrides.as_object().unwrap().len(), 0);
636    }
637
638    #[test]
639    fn router_default_param_schema_is_empty_object() {
640        // Wire and DB shapes must match: missing `param_schema` defaults to
641        // `{}`, never JSON `null`.
642        let json = r#"{
643            "id": "mrtr_00000000000000000000000000000001",
644            "name": "default",
645            "status": "active",
646            "created_at": "2024-01-01T00:00:00Z",
647            "updated_at": "2024-01-01T00:00:00Z"
648        }"#;
649        let router: ModelRouter = serde_json::from_str(json).unwrap();
650        assert!(
651            router.param_schema.is_object(),
652            "expected default param_schema to be a JSON object, got {:?}",
653            router.param_schema
654        );
655        assert_eq!(router.param_schema.as_object().unwrap().len(), 0);
656    }
657}