friday-framework-runtime 0.1.0a0__tar.gz → 0.1.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (15) hide show
  1. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/PKG-INFO +9 -8
  2. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/pyproject.toml +9 -7
  3. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/src/friday_runtime/config.py +200 -24
  4. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/src/friday_runtime/di.py +60 -27
  5. friday_framework_runtime-0.1.0a2/src/friday_runtime/model_library.py +172 -0
  6. friday_framework_runtime-0.1.0a2/tests_runtime/test_model_library.py +192 -0
  7. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/.gitignore +0 -0
  8. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/README.md +0 -0
  9. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/src/friday_runtime/__init__.py +0 -0
  10. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/src/friday_runtime/kernel.py +0 -0
  11. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/src/friday_runtime/tool_loading.py +0 -0
  12. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/tests_runtime/__init__.py +0 -0
  13. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/tests_runtime/test_config.py +0 -0
  14. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/tests_runtime/test_di.py +0 -0
  15. {friday_framework_runtime-0.1.0a0 → friday_framework_runtime-0.1.0a2}/tests_runtime/test_kernel.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: friday-framework-runtime
3
- Version: 0.1.0a0
3
+ Version: 0.1.0a2
4
4
  Summary: Headless runtime kernel for the Friday Agent Framework
5
5
  Project-URL: Homepage, https://github.com/CIChuck/agent-framework
6
6
  Project-URL: Repository, https://github.com/CIChuck/agent-framework
@@ -10,12 +10,13 @@ Project-URL: Source, https://github.com/CIChuck/agent-framework/tree/main/packag
10
10
  Author: Friday Team
11
11
  License-Expression: MIT
12
12
  Requires-Python: >=3.10
13
- Requires-Dist: friday-framework-agent==0.1.0a0
14
- Requires-Dist: friday-framework-core==0.1.0a0
15
- Requires-Dist: friday-framework-llm==0.1.0a0
16
- Requires-Dist: friday-framework-memory==0.1.0a0
17
- Requires-Dist: friday-framework-telemetry==0.1.0a0
18
- Requires-Dist: friday-framework-transcript==0.1.0a0
13
+ Requires-Dist: friday-framework-agent==0.1.0a2
14
+ Requires-Dist: friday-framework-core==0.1.0a2
15
+ Requires-Dist: friday-framework-llm==0.1.0a2
16
+ Requires-Dist: friday-framework-mcp==0.1.0a2
17
+ Requires-Dist: friday-framework-memory==0.1.0a2
18
+ Requires-Dist: friday-framework-telemetry==0.1.0a2
19
+ Requires-Dist: friday-framework-transcript==0.1.0a2
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
21
22
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
@@ -1,18 +1,19 @@
1
1
  [project]
2
2
  name = "friday-framework-runtime"
3
- version = "0.1.0a0"
3
+ version = "0.1.0a2"
4
4
  description = "Headless runtime kernel for the Friday Agent Framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = "MIT"
8
8
  authors = [{ name = "Friday Team" }]
9
9
  dependencies = [
10
- "friday-framework-core==0.1.0a0",
11
- "friday-framework-memory==0.1.0a0",
12
- "friday-framework-llm==0.1.0a0",
13
- "friday-framework-telemetry==0.1.0a0",
14
- "friday-framework-agent==0.1.0a0",
15
- "friday-framework-transcript==0.1.0a0",
10
+ "friday-framework-core==0.1.0a2",
11
+ "friday-framework-memory==0.1.0a2",
12
+ "friday-framework-llm==0.1.0a2",
13
+ "friday-framework-telemetry==0.1.0a2",
14
+ "friday-framework-agent==0.1.0a2",
15
+ "friday-framework-transcript==0.1.0a2",
16
+ "friday-framework-mcp==0.1.0a2",
16
17
  ]
17
18
 
18
19
  [project.urls]
@@ -47,3 +48,4 @@ friday-framework-llm = { workspace = true }
47
48
  friday-framework-telemetry = { workspace = true }
48
49
  friday-framework-agent = { workspace = true }
49
50
  friday-framework-transcript = { workspace = true }
51
+ friday-framework-mcp = { workspace = true }
@@ -14,9 +14,20 @@ from dataclasses import dataclass, field
14
14
  from pathlib import Path
15
15
  from typing import Any, Literal, cast
16
16
 
17
- import yaml # type: ignore[import-untyped]
17
+ import yaml
18
+ from friday_llm import (
19
+ ChatServiceBinding,
20
+ EmbeddingServiceBinding,
21
+ EmbeddingServiceSpec,
22
+ LLMServiceSpec,
23
+ ModelLibrary,
24
+ resolve_chat_service,
25
+ resolve_embedding_service,
26
+ )
18
27
  from friday_mcp.config import MCPConfig
19
28
 
29
+ from friday_runtime.model_library import load_model_library
30
+
20
31
 
21
32
  def _coerce_bool(value: str) -> bool:
22
33
  return value.strip().lower() in {"1", "true", "yes", "on"}
@@ -265,7 +276,7 @@ class LLMServiceConfig:
265
276
  @dataclass(frozen=True)
266
277
  class LLMPoolConfig:
267
278
  default_service: str
268
- services: dict[str, LLMServiceConfig]
279
+ services: dict[str, LLMServiceConfig | ChatServiceBinding]
269
280
 
270
281
 
271
282
  @dataclass(frozen=True)
@@ -307,7 +318,7 @@ class EmbeddingServiceConfig:
307
318
  @dataclass(frozen=True)
308
319
  class EmbeddingPoolConfig:
309
320
  default_service: str
310
- services: dict[str, EmbeddingServiceConfig]
321
+ services: dict[str, EmbeddingServiceConfig | EmbeddingServiceBinding]
311
322
 
312
323
 
313
324
  @dataclass(frozen=True)
@@ -373,15 +384,26 @@ class RuntimeConfig:
373
384
  llm_api_key: str | None = None
374
385
  llm_api_base: str | None = None
375
386
  llm_pool_default_service: str = "default"
376
- llm_pool_services: dict[str, LLMServiceConfig] = field(default_factory=dict)
387
+ llm_pool_services: dict[str, LLMServiceConfig | ChatServiceBinding] = field(
388
+ default_factory=dict
389
+ )
390
+ llm_service_specs: dict[str, LLMServiceSpec] = field(default_factory=dict)
377
391
 
378
392
  # Embedding Configuration
379
393
  embedding_model: str = "text-embedding-3-small"
380
394
  embedding_pool_default_service: str = "default"
381
- embedding_pool_services: dict[str, EmbeddingServiceConfig] = field(
395
+ embedding_pool_services: dict[
396
+ str,
397
+ EmbeddingServiceConfig | EmbeddingServiceBinding,
398
+ ] = field(default_factory=dict)
399
+ embedding_service_specs: dict[str, EmbeddingServiceSpec] = field(
382
400
  default_factory=dict
383
401
  )
384
402
 
403
+ # Canonical model-library configuration
404
+ model_library_path: str | None = None
405
+ model_library: ModelLibrary | None = None
406
+
385
407
  # Prompting Configuration
386
408
  prompting_base_system_prompt: str | None = None
387
409
 
@@ -482,28 +504,54 @@ class RuntimeConfig:
482
504
 
483
505
  if self.llm_pool_default_service not in self.llm_pool_services:
484
506
  raise ValueError("llm_pool.default_service must reference a configured service")
507
+ for name, service in self.llm_pool_services.items():
508
+ if isinstance(service, ChatServiceBinding) and name not in self.llm_service_specs:
509
+ raise ValueError(
510
+ f"canonical llm service {name!r} has no resolved service spec"
511
+ )
485
512
 
486
513
  self.embedding_pool_services = {
487
514
  name: self._coerce_embedding_service_config(service)
488
515
  for name, service in self.embedding_pool_services.items()
489
516
  }
490
517
  if not self.embedding_pool_services:
491
- default_llm_service = self.get_llm_service_config()
492
- self.embedding_pool_services = {
493
- self.embedding_pool_default_service: EmbeddingServiceConfig(
494
- provider=default_llm_service.provider,
495
- model=self.embedding_model,
496
- api_key=default_llm_service.api_key,
497
- api_base=default_llm_service.api_base,
518
+ default_llm_service = self.llm_pool_services[
519
+ self.llm_pool_default_service
520
+ ]
521
+ if isinstance(default_llm_service, LLMServiceConfig):
522
+ self.embedding_pool_services = {
523
+ self.embedding_pool_default_service: EmbeddingServiceConfig(
524
+ provider=default_llm_service.provider,
525
+ model=self.embedding_model,
526
+ api_key=default_llm_service.api_key,
527
+ api_base=default_llm_service.api_base,
528
+ )
529
+ }
530
+ else:
531
+ raise ValueError(
532
+ "canonical model-library configuration requires an explicit "
533
+ "embedding_pool service"
498
534
  )
499
- }
500
535
 
501
536
  if self.embedding_pool_default_service not in self.embedding_pool_services:
502
537
  raise ValueError(
503
538
  "embedding_pool.default_service must reference a configured service"
504
539
  )
505
540
 
506
- self.embedding_model = self.get_embedding_service_config().model
541
+ default_embedding = self.embedding_pool_services[
542
+ self.embedding_pool_default_service
543
+ ]
544
+ if isinstance(default_embedding, EmbeddingServiceConfig):
545
+ self.embedding_model = default_embedding.model
546
+ else:
547
+ try:
548
+ self.embedding_model = self.embedding_service_specs[
549
+ self.embedding_pool_default_service
550
+ ].model
551
+ except KeyError as error:
552
+ raise ValueError(
553
+ "canonical default embedding service has no resolved service spec"
554
+ ) from error
507
555
 
508
556
  if self.memory_extraction_llm_service is None:
509
557
  self.memory_extraction_llm_service = self.llm_pool_default_service
@@ -530,20 +578,32 @@ class RuntimeConfig:
530
578
  self.validate_transcript_path()
531
579
 
532
580
  @staticmethod
533
- def _coerce_service_config(value: LLMServiceConfig | Mapping[str, Any]) -> LLMServiceConfig:
581
+ def _coerce_service_config(
582
+ value: LLMServiceConfig | ChatServiceBinding | Mapping[str, Any],
583
+ ) -> LLMServiceConfig | ChatServiceBinding:
534
584
  if isinstance(value, LLMServiceConfig):
535
585
  return value
586
+ if isinstance(value, ChatServiceBinding):
587
+ return value
536
588
  if isinstance(value, Mapping):
589
+ if "profile" in value:
590
+ return ChatServiceBinding.model_validate(value)
537
591
  return LLMServiceConfig.from_dict(value)
538
592
  raise ValueError("llm_pool.services entries must be objects")
539
593
 
540
594
  @staticmethod
541
595
  def _coerce_embedding_service_config(
542
- value: EmbeddingServiceConfig | Mapping[str, Any]
543
- ) -> EmbeddingServiceConfig:
596
+ value: EmbeddingServiceConfig
597
+ | EmbeddingServiceBinding
598
+ | Mapping[str, Any],
599
+ ) -> EmbeddingServiceConfig | EmbeddingServiceBinding:
544
600
  if isinstance(value, EmbeddingServiceConfig):
545
601
  return value
602
+ if isinstance(value, EmbeddingServiceBinding):
603
+ return value
546
604
  if isinstance(value, Mapping):
605
+ if "profile" in value:
606
+ return EmbeddingServiceBinding.model_validate(value)
547
607
  return EmbeddingServiceConfig.from_dict(value)
548
608
  raise ValueError("embedding_pool.services entries must be objects")
549
609
 
@@ -648,9 +708,12 @@ class RuntimeConfig:
648
708
  def get_llm_service_config(self, service_name: str | None = None) -> LLMServiceConfig:
649
709
  selected = service_name or self.llm_pool_default_service
650
710
  try:
651
- return self.llm_pool_services[selected]
711
+ service = self.llm_pool_services[selected]
652
712
  except KeyError as exc:
653
713
  raise ValueError(f"Unknown llm service: {selected}") from exc
714
+ if not isinstance(service, LLMServiceConfig):
715
+ raise ValueError(f"LLM service {selected!r} uses a canonical profile")
716
+ return service
654
717
 
655
718
  def get_embedding_service_config(
656
719
  self,
@@ -658,9 +721,14 @@ class RuntimeConfig:
658
721
  ) -> EmbeddingServiceConfig:
659
722
  selected = service_name or self.embedding_pool_default_service
660
723
  try:
661
- return self.embedding_pool_services[selected]
724
+ service = self.embedding_pool_services[selected]
662
725
  except KeyError as exc:
663
726
  raise ValueError(f"Unknown embedding service: {selected}") from exc
727
+ if not isinstance(service, EmbeddingServiceConfig):
728
+ raise ValueError(
729
+ f"Embedding service {selected!r} uses a canonical profile"
730
+ )
731
+ return service
664
732
 
665
733
  def validate_runtime_roots(self) -> None:
666
734
  """
@@ -808,6 +876,12 @@ class RuntimeConfig:
808
876
  if yaml_path is not None:
809
877
  yaml_data = cls._load_yaml(pathlib_path=Path(yaml_path))
810
878
  cls._deep_merge(merged, yaml_data)
879
+ for pool_name in ("llm_pool", "embedding_pool"):
880
+ yaml_pool = yaml_data.get(pool_name)
881
+ if isinstance(yaml_pool, Mapping) and "services" in yaml_pool:
882
+ cast(dict[str, Any], merged[pool_name])["services"] = dict(
883
+ cast(Mapping[str, Any], yaml_pool["services"])
884
+ )
811
885
 
812
886
  allow_env = yaml_path is None or bool(
813
887
  merged["config_policy"]["allow_env_override"]
@@ -818,7 +892,63 @@ class RuntimeConfig:
818
892
  if overrides:
819
893
  cls._apply_flat_overrides(merged, overrides)
820
894
 
821
- return cls(**cls._nested_to_flat_kwargs(merged))
895
+ flat = cls._nested_to_flat_kwargs(merged)
896
+ canonical_llm = {
897
+ name: service
898
+ for name, service in cast(
899
+ dict[str, LLMServiceConfig | ChatServiceBinding],
900
+ flat["llm_pool_services"],
901
+ ).items()
902
+ if isinstance(service, ChatServiceBinding)
903
+ }
904
+ canonical_embeddings = {
905
+ name: service
906
+ for name, service in cast(
907
+ dict[str, EmbeddingServiceConfig | EmbeddingServiceBinding],
908
+ flat["embedding_pool_services"],
909
+ ).items()
910
+ if isinstance(service, EmbeddingServiceBinding)
911
+ }
912
+ model_library_section = cast(
913
+ Mapping[str, Any],
914
+ merged.get("model_library", {}),
915
+ )
916
+ configured_catalog_path = model_library_section.get("path")
917
+ if canonical_llm or canonical_embeddings:
918
+ if yaml_path is None or not isinstance(configured_catalog_path, str):
919
+ raise ValueError(
920
+ "canonical service bindings require model_library.path in a "
921
+ "Runtime YAML file"
922
+ )
923
+ loaded = load_model_library(
924
+ configured_catalog_path,
925
+ runtime_config_path=yaml_path,
926
+ environment=env_map,
927
+ allow_env_override=allow_env,
928
+ )
929
+ flat["model_library_path"] = str(loaded.path)
930
+ flat["model_library"] = loaded.library
931
+ flat["llm_service_specs"] = {
932
+ name: resolve_chat_service(loaded.library, binding, env_map)
933
+ for name, binding in canonical_llm.items()
934
+ }
935
+ flat["embedding_service_specs"] = {
936
+ name: resolve_embedding_service(loaded.library, binding, env_map)
937
+ for name, binding in canonical_embeddings.items()
938
+ }
939
+ elif configured_catalog_path is not None:
940
+ if yaml_path is None or not isinstance(configured_catalog_path, str):
941
+ raise ValueError("model_library.path must be a string in Runtime YAML")
942
+ loaded = load_model_library(
943
+ configured_catalog_path,
944
+ runtime_config_path=yaml_path,
945
+ environment=env_map,
946
+ allow_env_override=allow_env,
947
+ )
948
+ flat["model_library_path"] = str(loaded.path)
949
+ flat["model_library"] = loaded.library
950
+
951
+ return cls(**flat)
822
952
 
823
953
  @classmethod
824
954
  def _default_source_data(cls) -> dict[str, Any]:
@@ -832,6 +962,7 @@ class RuntimeConfig:
832
962
  "workflow_mode": "canonical",
833
963
  "unknown_key_policy": "fail",
834
964
  },
965
+ "model_library": {"path": None},
835
966
  "llm": {
836
967
  "provider": "openai",
837
968
  "model": "gpt-4",
@@ -1122,6 +1253,7 @@ class RuntimeConfig:
1122
1253
  @classmethod
1123
1254
  def _validate_yaml_keys(cls, payload: Mapping[str, Any]) -> None:
1124
1255
  allowed_top_level = {
1256
+ "model_library",
1125
1257
  "llm_pool",
1126
1258
  "llm",
1127
1259
  "embedding_pool",
@@ -1143,6 +1275,7 @@ class RuntimeConfig:
1143
1275
  raise ValueError(f"Unknown runtime config keys: {sorted(unknown_top)}")
1144
1276
 
1145
1277
  section_keys: dict[str, set[str]] = {
1278
+ "model_library": {"path"},
1146
1279
  "llm": {"provider", "model", "api_key", "api_base"},
1147
1280
  "embedding": {"model"},
1148
1281
  "prompting": {"base_system_prompt"},
@@ -1199,6 +1332,12 @@ class RuntimeConfig:
1199
1332
  if not isinstance(role_bindings, dict):
1200
1333
  raise ValueError("agents.role_bindings must be an object")
1201
1334
 
1335
+ model_library = payload.get("model_library", {})
1336
+ if isinstance(model_library, dict):
1337
+ path_value = model_library.get("path")
1338
+ if path_value is not None and not isinstance(path_value, str):
1339
+ raise ValueError("model_library.path must be a string")
1340
+
1202
1341
  tools = payload.get("tools", {})
1203
1342
  if isinstance(tools, dict):
1204
1343
  providers = tools.get("providers")
@@ -1269,11 +1408,28 @@ class RuntimeConfig:
1269
1408
  services = llm_pool["services"]
1270
1409
  if not isinstance(services, dict):
1271
1410
  raise ValueError("llm_pool.services must be an object")
1272
- allowed_service_keys = {"provider", "model", "api_key", "api_base", "resilience"}
1411
+ allowed_legacy_service_keys = {
1412
+ "provider",
1413
+ "model",
1414
+ "api_key",
1415
+ "api_base",
1416
+ "resilience",
1417
+ }
1418
+ allowed_profile_service_keys = {
1419
+ "profile",
1420
+ "credential",
1421
+ "resilience_overrides",
1422
+ "generation_overrides",
1423
+ }
1273
1424
  allowed_resilience_keys = {"timeout_seconds", "max_retries"}
1274
1425
  for service_name, config in services.items():
1275
1426
  if not isinstance(config, dict):
1276
1427
  raise ValueError(f"llm_pool.services.{service_name} must be an object")
1428
+ allowed_service_keys = (
1429
+ allowed_profile_service_keys
1430
+ if "profile" in config
1431
+ else allowed_legacy_service_keys
1432
+ )
1277
1433
  unknown_service = set(config) - allowed_service_keys
1278
1434
  if unknown_service:
1279
1435
  raise ValueError(
@@ -1298,13 +1454,29 @@ class RuntimeConfig:
1298
1454
  services = embedding_pool["services"]
1299
1455
  if not isinstance(services, dict):
1300
1456
  raise ValueError("embedding_pool.services must be an object")
1301
- allowed_service_keys = {"provider", "model", "api_key", "api_base", "resilience"}
1457
+ allowed_legacy_service_keys = {
1458
+ "provider",
1459
+ "model",
1460
+ "api_key",
1461
+ "api_base",
1462
+ "resilience",
1463
+ }
1464
+ allowed_profile_service_keys = {
1465
+ "profile",
1466
+ "credential",
1467
+ "resilience_overrides",
1468
+ }
1302
1469
  allowed_resilience_keys = {"timeout_seconds", "max_retries"}
1303
1470
  for service_name, config in services.items():
1304
1471
  if not isinstance(config, dict):
1305
1472
  raise ValueError(
1306
1473
  f"embedding_pool.services.{service_name} must be an object"
1307
1474
  )
1475
+ allowed_service_keys = (
1476
+ allowed_profile_service_keys
1477
+ if "profile" in config
1478
+ else allowed_legacy_service_keys
1479
+ )
1308
1480
  unknown_service = set(config) - allowed_service_keys
1309
1481
  if unknown_service:
1310
1482
  raise ValueError(
@@ -1545,14 +1717,18 @@ class RuntimeConfig:
1545
1717
  "llm_api_base": data["llm"].get("api_base"),
1546
1718
  "llm_pool_default_service": default_service_name,
1547
1719
  "llm_pool_services": {
1548
- name: LLMServiceConfig.from_dict(cast(Mapping[str, Any], config))
1720
+ name: cls._coerce_service_config(
1721
+ cast(Mapping[str, Any], config)
1722
+ )
1549
1723
  for name, config in llm_services.items()
1550
1724
  },
1551
1725
  "embedding_pool_default_service": embedding_pool_data.get(
1552
1726
  "default_service", "default"
1553
1727
  ),
1554
1728
  "embedding_pool_services": {
1555
- name: EmbeddingServiceConfig.from_dict(cast(Mapping[str, Any], config))
1729
+ name: cls._coerce_embedding_service_config(
1730
+ cast(Mapping[str, Any], config)
1731
+ )
1556
1732
  for name, config in embedding_services.items()
1557
1733
  },
1558
1734
  "embedding_model": data["embedding"]["model"],
@@ -42,7 +42,11 @@ from friday_memory import (
42
42
  )
43
43
  from friday_transcript import SQLiteTranscriptStore
44
44
 
45
- from friday_runtime.config import RuntimeConfig
45
+ from friday_runtime.config import (
46
+ EmbeddingServiceConfig,
47
+ LLMServiceConfig,
48
+ RuntimeConfig,
49
+ )
46
50
  from friday_runtime.tool_loading import register_runtime_tools
47
51
 
48
52
  logger = logging.getLogger(__name__)
@@ -136,49 +140,78 @@ class DIContainer:
136
140
  """Create LLM and embedding services."""
137
141
  self.llm_pool = {}
138
142
  for service_name, service_config in self._config.llm_pool.services.items():
139
- self.llm_pool[service_name] = LiteLLMService(
140
- model_name=service_config.model,
141
- api_key=service_config.api_key,
142
- api_base=service_config.api_base,
143
- telemetry=self.telemetry,
144
- resilience=ResilienceConfig(
145
- max_retries=service_config.resilience.max_retries,
146
- timeout_seconds=float(service_config.resilience.timeout_seconds),
147
- ),
148
- )
143
+ chat_spec = self._config.llm_service_specs.get(service_name)
144
+ if chat_spec is not None:
145
+ self.llm_pool[service_name] = LiteLLMService.from_spec(
146
+ chat_spec,
147
+ telemetry=self.telemetry,
148
+ )
149
+ else:
150
+ if not isinstance(service_config, LLMServiceConfig):
151
+ raise RuntimeError(
152
+ f"LLM service {service_name!r} has no resolved specification"
153
+ )
154
+ self.llm_pool[service_name] = LiteLLMService(
155
+ model_name=service_config.model,
156
+ api_key=service_config.api_key,
157
+ api_base=service_config.api_base,
158
+ telemetry=self.telemetry,
159
+ resilience=ResilienceConfig(
160
+ max_retries=service_config.resilience.max_retries,
161
+ timeout_seconds=float(
162
+ service_config.resilience.timeout_seconds
163
+ ),
164
+ ),
165
+ )
149
166
 
150
167
  self.llm = self.llm_pool[self._config.llm_pool.default_service]
151
- default_service = self._config.get_llm_service_config()
152
168
 
153
169
  self.embedding_pool = {}
154
170
  for (
155
171
  service_name,
156
172
  embedding_service_config,
157
173
  ) in self._config.embedding_pool.services.items():
158
- self.embedding_pool[service_name] = LiteLLMEmbeddingService(
159
- model_name=embedding_service_config.model,
160
- api_key=embedding_service_config.api_key,
161
- api_base=embedding_service_config.api_base,
162
- telemetry=self.telemetry,
163
- resilience=ResilienceConfig(
164
- max_retries=embedding_service_config.resilience.max_retries,
165
- timeout_seconds=float(
166
- embedding_service_config.resilience.timeout_seconds
174
+ embedding_spec = self._config.embedding_service_specs.get(service_name)
175
+ if embedding_spec is not None:
176
+ self.embedding_pool[service_name] = (
177
+ LiteLLMEmbeddingService.from_spec(
178
+ embedding_spec,
179
+ telemetry=self.telemetry,
180
+ )
181
+ )
182
+ else:
183
+ if not isinstance(
184
+ embedding_service_config,
185
+ EmbeddingServiceConfig,
186
+ ):
187
+ raise RuntimeError(
188
+ f"Embedding service {service_name!r} has no resolved "
189
+ "specification"
190
+ )
191
+ self.embedding_pool[service_name] = LiteLLMEmbeddingService(
192
+ model_name=embedding_service_config.model,
193
+ api_key=embedding_service_config.api_key,
194
+ api_base=embedding_service_config.api_base,
195
+ telemetry=self.telemetry,
196
+ resilience=ResilienceConfig(
197
+ max_retries=(
198
+ embedding_service_config.resilience.max_retries
199
+ ),
200
+ timeout_seconds=float(
201
+ embedding_service_config.resilience.timeout_seconds
202
+ ),
167
203
  ),
168
- ),
169
- )
204
+ )
170
205
 
171
206
  self.embeddings = self.embedding_pool[self._config.embedding_pool.default_service]
172
- default_embedding_service = self._config.get_embedding_service_config()
173
-
174
207
  logger.info(
175
208
  "LLM pool configured with default service '%s' (%s) across %d services; "
176
209
  "embedding pool default '%s' (%s) across %d services",
177
210
  self._config.llm_pool.default_service,
178
- default_service.model,
211
+ self.llm.model,
179
212
  len(self.llm_pool),
180
213
  self._config.embedding_pool.default_service,
181
- default_embedding_service.model,
214
+ self.embeddings.model,
182
215
  len(self.embedding_pool),
183
216
  )
184
217
 
@@ -0,0 +1,172 @@
1
+ """Strict local-file loading for Friday LLM model libraries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ import yaml
11
+ from friday_llm import ModelLibrary
12
+
13
+ MAX_MODEL_LIBRARY_BYTES = 1024 * 1024
14
+ MAX_MODEL_LIBRARY_DEPTH = 12
15
+ MAX_MODEL_LIBRARY_NODES = 10_000
16
+ MAX_MODEL_LIBRARY_STRING = 4096
17
+
18
+
19
+ class ModelLibraryLoadError(ValueError):
20
+ """Raised when a model-library file violates Runtime loading policy."""
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class LoadedModelLibrary:
25
+ """Validated catalog and the canonical path used to load it."""
26
+
27
+ path: Path
28
+ library: ModelLibrary
29
+
30
+
31
+ class _StrictLoader(yaml.SafeLoader):
32
+ """Safe YAML loader that rejects duplicate mapping keys."""
33
+
34
+ def construct_mapping(
35
+ self,
36
+ node: yaml.MappingNode,
37
+ deep: bool = False,
38
+ ) -> dict[object, object]:
39
+ seen: set[object] = set()
40
+ for key_node, _ in node.value:
41
+ if key_node.tag == "tag:yaml.org,2002:merge":
42
+ raise ModelLibraryLoadError("YAML merge keys are not allowed")
43
+ key = self.construct_object(key_node, deep=False)
44
+ try:
45
+ duplicate = key in seen
46
+ except TypeError as error:
47
+ raise ModelLibraryLoadError("mapping keys must be scalar") from error
48
+ if duplicate:
49
+ raise ModelLibraryLoadError(f"duplicate YAML mapping key: {key!r}")
50
+ seen.add(key)
51
+ return super().construct_mapping(node, deep=deep)
52
+
53
+
54
+ def load_model_library(
55
+ configured_path: str,
56
+ *,
57
+ runtime_config_path: str | Path,
58
+ environment: Mapping[str, str],
59
+ allow_env_override: bool,
60
+ ) -> LoadedModelLibrary:
61
+ """Load one bounded local model library relative to its Runtime YAML."""
62
+
63
+ selected_path = configured_path
64
+ if allow_env_override:
65
+ override = environment.get("FRIDAY_MODELS_CONFIG")
66
+ if override is not None and override.strip():
67
+ selected_path = override
68
+ resolved_path = _resolve_catalog_path(
69
+ selected_path,
70
+ runtime_config_path=Path(runtime_config_path),
71
+ )
72
+ try:
73
+ file_size = resolved_path.stat().st_size
74
+ except OSError as error:
75
+ raise ModelLibraryLoadError(
76
+ f"unable to inspect model library: {resolved_path}"
77
+ ) from error
78
+ if file_size > MAX_MODEL_LIBRARY_BYTES:
79
+ raise ModelLibraryLoadError(
80
+ f"model library exceeds {MAX_MODEL_LIBRARY_BYTES} bytes"
81
+ )
82
+ try:
83
+ document = resolved_path.read_text(encoding="utf-8")
84
+ except (OSError, UnicodeError) as error:
85
+ raise ModelLibraryLoadError(
86
+ f"unable to read model library: {resolved_path}"
87
+ ) from error
88
+ _reject_yaml_references(document)
89
+ try:
90
+ payload = yaml.load(document, Loader=_StrictLoader)
91
+ except ModelLibraryLoadError:
92
+ raise
93
+ except yaml.YAMLError as error:
94
+ raise ModelLibraryLoadError("invalid model-library YAML") from error
95
+ if not isinstance(payload, dict):
96
+ raise ModelLibraryLoadError("model library must be a YAML mapping")
97
+ _validate_structure_bounds(payload)
98
+ try:
99
+ library = ModelLibrary.model_validate(payload)
100
+ except ValueError as error:
101
+ raise ModelLibraryLoadError("invalid model-library schema") from error
102
+ return LoadedModelLibrary(path=resolved_path, library=library)
103
+
104
+
105
+ def _resolve_catalog_path(
106
+ configured_path: str,
107
+ *,
108
+ runtime_config_path: Path,
109
+ ) -> Path:
110
+ if not configured_path.strip():
111
+ raise ModelLibraryLoadError("model_library.path must be non-empty")
112
+ parsed = urlsplit(configured_path)
113
+ if parsed.scheme or parsed.netloc:
114
+ raise ModelLibraryLoadError("model_library.path must be a local file path")
115
+ raw_path = Path(configured_path).expanduser()
116
+ candidate = (
117
+ raw_path
118
+ if raw_path.is_absolute()
119
+ else runtime_config_path.expanduser().resolve().parent / raw_path
120
+ )
121
+ try:
122
+ resolved = candidate.resolve(strict=True)
123
+ except OSError as error:
124
+ raise ModelLibraryLoadError(
125
+ f"model library does not exist: {candidate}"
126
+ ) from error
127
+ if not resolved.is_file():
128
+ raise ModelLibraryLoadError(
129
+ f"model library is not a regular file: {resolved}"
130
+ )
131
+ return resolved
132
+
133
+
134
+ def _reject_yaml_references(document: str) -> None:
135
+ try:
136
+ events = yaml.parse(document, Loader=yaml.SafeLoader)
137
+ for event in events:
138
+ if isinstance(event, yaml.AliasEvent) or getattr(event, "anchor", None):
139
+ raise ModelLibraryLoadError(
140
+ "YAML anchors and aliases are not allowed"
141
+ )
142
+ except ModelLibraryLoadError:
143
+ raise
144
+ except yaml.YAMLError as error:
145
+ raise ModelLibraryLoadError("invalid model-library YAML") from error
146
+
147
+
148
+ def _validate_structure_bounds(payload: dict[object, object]) -> None:
149
+ nodes = 0
150
+ pending: list[tuple[object, int]] = [(payload, 1)]
151
+ while pending:
152
+ value, depth = pending.pop()
153
+ nodes += 1
154
+ if nodes > MAX_MODEL_LIBRARY_NODES:
155
+ raise ModelLibraryLoadError(
156
+ f"model library exceeds {MAX_MODEL_LIBRARY_NODES} YAML nodes"
157
+ )
158
+ if depth > MAX_MODEL_LIBRARY_DEPTH:
159
+ raise ModelLibraryLoadError(
160
+ f"model library exceeds YAML depth {MAX_MODEL_LIBRARY_DEPTH}"
161
+ )
162
+ if isinstance(value, str) and len(value) > MAX_MODEL_LIBRARY_STRING:
163
+ raise ModelLibraryLoadError(
164
+ f"model library string exceeds {MAX_MODEL_LIBRARY_STRING} characters"
165
+ )
166
+ if isinstance(value, dict):
167
+ for key, child in value.items():
168
+ pending.append((key, depth + 1))
169
+ pending.append((child, depth + 1))
170
+ elif isinstance(value, list):
171
+ for child in value:
172
+ pending.append((child, depth + 1))
@@ -0,0 +1,192 @@
1
+ """Runtime model-library file-policy tests."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from friday_core.profiles import MemoryProfile
7
+ from friday_runtime.config import RuntimeConfig
8
+ from friday_runtime.di import DIContainer
9
+ from friday_runtime.model_library import ModelLibraryLoadError, load_model_library
10
+
11
+
12
+ def _write_catalog(path: Path, *, marker: str = "local") -> None:
13
+ path.write_text(
14
+ f"""
15
+ schema: friday.model-library/v1
16
+ endpoints:
17
+ {marker}:
18
+ api_base: http://127.0.0.1:1234/v1
19
+ provider: openai_compatible
20
+ profiles:
21
+ chat:
22
+ kind: chat
23
+ endpoint: {marker}
24
+ dialect: openai_chat_completions
25
+ model: openai/qwen3.8
26
+ model_target:
27
+ model_id: qwen3.8
28
+ family: qwen3_8
29
+ revision: exact
30
+ """.strip()
31
+ )
32
+
33
+
34
+ def test_relative_catalog_path_is_resolved_against_runtime_yaml(tmp_path: Path) -> None:
35
+ config_dir = tmp_path / "configuration"
36
+ config_dir.mkdir()
37
+ runtime_path = config_dir / "config.yaml"
38
+ runtime_path.write_text("model_library:\n path: models-config.yaml\n")
39
+ catalog_path = config_dir / "models-config.yaml"
40
+ _write_catalog(catalog_path)
41
+
42
+ loaded = load_model_library(
43
+ "models-config.yaml",
44
+ runtime_config_path=runtime_path,
45
+ environment={},
46
+ allow_env_override=False,
47
+ )
48
+
49
+ assert loaded.path == catalog_path.resolve()
50
+ assert "chat" in loaded.library.profiles
51
+
52
+
53
+ def test_environment_path_override_requires_policy_opt_in(tmp_path: Path) -> None:
54
+ runtime_path = tmp_path / "config.yaml"
55
+ default_path = tmp_path / "default.yaml"
56
+ override_path = tmp_path / "override.yaml"
57
+ _write_catalog(default_path, marker="default")
58
+ _write_catalog(override_path, marker="override")
59
+ environment = {"FRIDAY_MODELS_CONFIG": str(override_path)}
60
+
61
+ disabled = load_model_library(
62
+ str(default_path),
63
+ runtime_config_path=runtime_path,
64
+ environment=environment,
65
+ allow_env_override=False,
66
+ )
67
+ enabled = load_model_library(
68
+ str(default_path),
69
+ runtime_config_path=runtime_path,
70
+ environment=environment,
71
+ allow_env_override=True,
72
+ )
73
+
74
+ assert disabled.path == default_path.resolve()
75
+ assert enabled.path == override_path.resolve()
76
+
77
+
78
+ @pytest.mark.parametrize(
79
+ "document",
80
+ (
81
+ "schema: friday.model-library/v1\nschema: friday.model-library/v1\n",
82
+ "schema: &schema friday.model-library/v1\ncopy: *schema\n",
83
+ "schema: friday.model-library/v1\nvalue: !custom tagged\n",
84
+ "schema: friday.model-library/v1\nbase: &base {provider: openai}\nmerged: {<<: *base}\n",
85
+ ),
86
+ )
87
+ def test_strict_loader_rejects_ambiguous_yaml(
88
+ tmp_path: Path,
89
+ document: str,
90
+ ) -> None:
91
+ catalog_path = tmp_path / "models.yaml"
92
+ catalog_path.write_text(document)
93
+
94
+ with pytest.raises(ModelLibraryLoadError):
95
+ load_model_library(
96
+ str(catalog_path),
97
+ runtime_config_path=tmp_path / "config.yaml",
98
+ environment={},
99
+ allow_env_override=False,
100
+ )
101
+
102
+
103
+ def test_remote_catalog_is_rejected(tmp_path: Path) -> None:
104
+ with pytest.raises(ModelLibraryLoadError, match="local file"):
105
+ load_model_library(
106
+ "https://example.test/models.yaml",
107
+ runtime_config_path=tmp_path / "config.yaml",
108
+ environment={},
109
+ allow_env_override=False,
110
+ )
111
+
112
+
113
+ def test_runtime_resolves_canonical_pools_and_di_uses_factories(
114
+ tmp_path: Path,
115
+ ) -> None:
116
+ install_root = tmp_path / "install"
117
+ runtime_parent = tmp_path / "runtime"
118
+ workspace_root = tmp_path / "workspace"
119
+ install_root.mkdir()
120
+ runtime_parent.mkdir()
121
+ workspace_root.mkdir()
122
+ catalog_path = tmp_path / "models-config.yaml"
123
+ catalog_path.write_text(
124
+ """
125
+ schema: friday.model-library/v1
126
+ credentials:
127
+ local_key:
128
+ env: FRIDAY_TEST_LLM_KEY
129
+ endpoints:
130
+ local:
131
+ api_base: http://127.0.0.1:1234/v1
132
+ provider: openai_compatible
133
+ serving_engine: lm_studio
134
+ default_credential: local_key
135
+ profiles:
136
+ chat:
137
+ kind: chat
138
+ endpoint: local
139
+ dialect: openai_chat_completions
140
+ model: openai/qwen3.8
141
+ model_target:
142
+ model_id: qwen3.8
143
+ family: qwen3_8
144
+ revision: exact
145
+ embedding:
146
+ kind: embedding
147
+ endpoint: local
148
+ dialect: openai_embeddings
149
+ model: openai/nomic-embed
150
+ model_target:
151
+ model_id: nomic-embed
152
+ family: nomic_embed
153
+ revision: exact
154
+ """.strip()
155
+ )
156
+ runtime_path = tmp_path / "config.yaml"
157
+ runtime_path.write_text(
158
+ f"""
159
+ model_library:
160
+ path: ./models-config.yaml
161
+ runtime_paths:
162
+ install_root: {install_root}
163
+ runtime_data_root: {runtime_parent / "friday"}
164
+ workspace_root: {workspace_root}
165
+ llm_pool:
166
+ default_service: default
167
+ services:
168
+ default:
169
+ profile: chat
170
+ embedding_pool:
171
+ default_service: default
172
+ services:
173
+ default:
174
+ profile: embedding
175
+ """.strip()
176
+ )
177
+
178
+ config = RuntimeConfig.from_sources(
179
+ yaml_path=runtime_path,
180
+ env={"FRIDAY_TEST_LLM_KEY": "redacted-local-key"},
181
+ )
182
+ container = DIContainer(config, MemoryProfile.FULL)
183
+ container._create_llm_services()
184
+
185
+ assert config.model_library_path == str(catalog_path.resolve())
186
+ assert config.llm_api_key is None
187
+ assert container.llm is not None
188
+ assert container.llm.model == "openai/qwen3.8"
189
+ assert container.llm.api_key == "redacted-local-key"
190
+ assert container.llm.execution_target is not None
191
+ assert container.embeddings is not None
192
+ assert container.embeddings.execution_target is not None