-
Notifications
You must be signed in to change notification settings - Fork 557
Expand file tree
/
Copy pathconfig.py
More file actions
3501 lines (3024 loc) · 149 KB
/
Copy pathconfig.py
File metadata and controls
3501 lines (3024 loc) · 149 KB
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
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""GatewayConfig — Pydantic Settings for the gateway."""
from __future__ import annotations
import copy
import ipaddress
import logging
import os
import threading
import warnings
from enum import StrEnum
from pathlib import Path
from typing import Any, Literal, cast
from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
PrivateAttr,
SerializeAsAny,
field_validator,
model_validator,
)
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
from opensquilla import __version__
from opensquilla.gateway.config_migration import (
LATEST_CONFIG_VERSION,
ConfigParseError,
backup_and_write_migrated_config,
migrate_config_payload,
)
from opensquilla.paths import default_opensquilla_home, native_io_path
from opensquilla.provider.credentials import (
credential_provider_hint,
endpoint_provider_hint,
)
from opensquilla.provider.preset_registry import get_preset, legacy_profile_ids
from opensquilla.router_tiers import (
CUSTOM_B5_MAX_PROPOSERS,
CUSTOM_B5_MAX_TOTAL_CALLS,
CUSTOM_B5_MIN_PROPOSERS,
CUSTOM_B5_SELECTION_MODE,
DEFAULT_TEXT_TIER,
ENSEMBLE_CANDIDATE_ROLES,
LEGACY_OPENROUTER_MODEL_OPTIONS,
ROUTER_TIER_ENSEMBLE_SELECTION_MODES,
STATIC_B5_SELECTION_MODE_PROVIDERS, # noqa: F401 - legacy import surface
STATIC_B5_SELECTION_MODES,
STATIC_OPENROUTER_B5_SELECTION_MODE,
STATIC_TOKENRHYTHM_B5_SELECTION_MODE, # noqa: F401 - legacy import surface
TEXT_TIERS,
EnsembleSelectionMode,
TierConfig,
effective_ensemble_selection_mode,
effective_tier_ensemble_selection_modes,
normalize_text_tier,
normalize_tier_mapping,
)
from opensquilla.sandbox.config import SandboxSettings
from opensquilla.search.types import DEFAULT_SEARCH_MAX_RESULTS, MAX_SEARCH_RESULTS
from opensquilla.session.compaction_lifecycle import (
DEFAULT_FLUSH_TRIGGERS,
FlushTrigger,
normalize_flush_triggers_strict,
)
logger = logging.getLogger(__name__)
_LEGACY_CONTROL_UI_FRONTEND_WARNING = (
"control_ui.frontend='legacy' is deprecated and no longer selects the "
"retired vanilla-JS UI; Vue is always served. Remove this setting or set "
"it to 'vue'."
)
class ContextOverflowPolicy(StrEnum):
"""What to do when a turn's effective input size exceeds the budget.
The default is :attr:`AUTO_SUMMARIZE` so that
existing deployments degrade gracefully — older history is summarised
and the turn retried once. ``HARD_TRUNCATE`` drops oldest turns until
the payload fits. ``REFUSE`` short-circuits the turn with a stable
error envelope for operators who want explicit backpressure.
"""
AUTO_SUMMARIZE = "auto_summarize"
HARD_TRUNCATE = "hard_truncate"
REFUSE = "refuse"
class AuthConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_AUTH_")
token: str | None = None
password: str | None = None
mode: str = "none" # none | token | password | trusted-proxy
trusted_proxy: str | None = None
token_scopes: list[str] = Field(default_factory=lambda: ["operator.admin"])
allowed_roles: list[str] = Field(default_factory=lambda: ["operator", "node"])
# Empty means the built-in loopback/RFC1918/ULA set. Custom values may
# narrow that set but can never widen it to public address space.
allowed_client_cidrs: list[str] = Field(default_factory=list)
@field_validator("allowed_client_cidrs")
@classmethod
def _validate_allowed_client_cidrs(cls, values: list[str]) -> list[str]:
private_v4 = tuple(
ipaddress.IPv4Network(value)
for value in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
)
private_v6 = tuple(
ipaddress.IPv6Network(value)
for value in ("::1/128", "fc00::/7")
)
normalized: list[str] = []
for raw in values:
network = ipaddress.ip_network(str(raw).strip(), strict=False)
allowed = (
any(network.subnet_of(parent) for parent in private_v4)
if isinstance(network, ipaddress.IPv4Network)
else any(network.subnet_of(parent) for parent in private_v6)
)
if not allowed:
raise ValueError(
"auth.allowed_client_cidrs may only narrow loopback, "
"RFC1918, or IPv6 ULA networks"
)
text = network.with_prefixlen
if text not in normalized:
normalized.append(text)
return normalized
class CorsConfig(BaseSettings):
"""Cross-origin resource sharing headers for the gateway's HTTP surface.
``allowed_origins`` defaults to empty — no CORS headers are emitted, so
browsers refuse cross-origin reads. The Web UI is served same-origin from
the gateway itself and non-browser clients (CLI, desktop app, curl) are
unaffected, so nothing needs CORS out of the box. Operators hosting a
separate frontend opt in by listing its exact origins here.
"""
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_CORS_")
allowed_origins: list[str] = Field(default_factory=list)
allow_credentials: bool = True
allowed_methods: list[str] = Field(default_factory=lambda: ["*"])
allowed_headers: list[str] = Field(default_factory=lambda: ["*"])
class AttachmentsConfig(BaseSettings):
"""Transcript attachment persistence settings."""
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_ATTACHMENTS_")
persist_transcripts: bool = True
media_root: str | None = None # default resolved from cache dir at boot
transcript_disk_budget_bytes: int = 2 * 1024 * 1024 * 1024 # 2 GB
artifact_max_bytes: int = 30 * 1024 * 1024
artifact_disk_budget_bytes: int = 512 * 1024 * 1024
# Admission policy for opaque attachment types (archives, binaries,
# audio/video, unknown formats). Opaque bytes are never parsed or inlined
# into a provider prompt — they are staged into the agent workspace for
# tool access only. False restores the rendered-types-only admission gate.
accept_opaque: bool = True
opaque_max_bytes: int = 30 * 1024 * 1024
# Aggregate RAM ceiling for the in-memory staged-upload store. When
# reached, new uploads are rejected (HTTP 507 UPLOAD_STORE_FULL) instead
# of evicting staged entries, preserving the file_uuid TTL promise.
# Applied at gateway construction; changing it requires a restart.
upload_store_max_total_bytes: int = 300 * 1024 * 1024
# Disk budget for attachment copies materialized into an agent workspace
# (<workspace>/.opensquilla/attachments). When exceeded, new
# materializations degrade to an unavailable marker; nothing is evicted.
workspace_attachment_disk_budget_bytes: int = 1024 * 1024 * 1024
class RateLimitConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_RATE_")
enabled: bool = True
max_requests: int = 100
window_seconds: int = 60
class ControlUiConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_CONTROL_UI_")
enabled: bool = True
base_path: str = "/control"
# Retained temporarily so existing TOML files and environment overrides do
# not fail gateway validation after the vanilla-JS client is retired. Vue
# is the only runtime value; the validator below maps the historical
# ``legacy`` spelling to it with a deprecation warning.
frontend: Literal["vue"] = "vue"
# Default UI locale served on first paint when the browser has no saved
# preference, and the Gateway-wide language for fixed channel notices.
# The client (localStorage) and a manual switch always override it. Anything
# zh* clamps to zh-Hans; anything else to en.
default_locale: Literal["en", "zh-Hans", "ja", "fr", "de", "es"] = "en"
allowed_origins: list[str] = Field(default_factory=list)
@field_validator("base_path")
@classmethod
def _strip_trailing_slash(cls, v: str) -> str:
# Keep the root mount explicit. Returning ``""`` for ``"/"`` makes
# prefix checks such as ``path.startswith(base_path)`` match every
# request, including authenticated API routes.
return v.rstrip("/") or "/"
@field_validator("frontend", mode="before")
@classmethod
def _normalize_frontend(cls, v: object) -> object:
if isinstance(v, str):
normalized = v.strip().lower()
if normalized == "legacy":
warnings.warn(
_LEGACY_CONTROL_UI_FRONTEND_WARNING,
DeprecationWarning,
stacklevel=2,
)
logger.warning(_LEGACY_CONTROL_UI_FRONTEND_WARNING)
return "vue"
return normalized
return v
@field_validator("default_locale", mode="before")
@classmethod
def _normalize_locale(cls, v: object) -> object:
if isinstance(v, str):
s = v.strip().lower()
if s.startswith("zh"):
return "zh-Hans"
for code in ("ja", "fr", "de", "es"):
if s.startswith(code):
return code
return "en"
return v
class PrivacyConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_PRIVACY_")
disable_network_observability: bool = False
class SkillsConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_SKILLS_")
workspace_dir: str | None = None
managed_dir: str | None = None
allow_bundled: bool = True
extra_dirs: list[str] = Field(default_factory=list)
# Names of skills the operator has turned off (e.g. via the control-UI
# plugin toggle). A disabled skill is gated out of the agent's view.
disabled: list[str] = Field(default_factory=list)
# Coding mode (control-UI toggle). When ON, the agent operates in a
# locked coding mode: the code-task plugin is available and a directive
# steers every turn through it. When OFF, code-task is unreachable through
# every skill API. Default OFF — coding mode is opt-in.
coding_mode: bool = False
max_skills_prompt_chars: int = 8000
filter_enabled: bool = False
filter_top_k: int = 5
# "system" = full system prompt (default)
# "user_context" = ephemeral user-role context, after history and before current user
# "user_message" = legacy compact system-prompt index
injection_mode: str = "system"
# Relevance filtering is opt-in. Keep the default path dependency-free.
filter_strategy: Literal["lexical", "semantic", "hybrid"] = "lexical"
filter_lexical_top_n: int = 20
filter_semantic_top_n: int = 20
filter_rrf_k: int = 60
filter_embedding_model: str = "BAAI/bge-small-zh-v1.5"
class ToolsConfig(BaseModel):
"""Top-level runtime tool policy configuration."""
profile: (
Literal[
"full",
"minimal",
"memory_only",
"coding",
"messaging",
"repo_coding_source_edit",
"repo_coding_source_edit_strict",
"repo_coding_source_edit_v2",
"repo_coding_source_edit_balanced",
"repo_coding_source_edit_patch_fallback",
"repo_coding_scaffold_edit",
"repo_coding_scaffold_patch",
]
| None
) = None
allow: list[str] = Field(default_factory=list)
deny: list[str] = Field(default_factory=list)
also_allow: list[str] = Field(default_factory=list)
# Model-facing tool description overrides. Keys name a tool
# ("exec_command") or a parameter ("exec_command.command" — dotted keys
# must be quoted in TOML); values replace the matching description
# verbatim. Inert unless the OPENSQUILLA_TOOL_DESCRIPTION_OVERRIDES env
# var enables them ("config"/"on", or a .toml/.json override file path).
description_overrides: dict[str, str] = Field(default_factory=dict)
workspace_write_deny_globs: list[str] = Field(default_factory=list)
file_edit_requires_fresh_read: bool | None = None
file_edit_flexible_recovery: bool | None = None
trusted_fake_ip_cidrs: list[str] = Field(default_factory=list)
@field_validator("trusted_fake_ip_cidrs")
@classmethod
def _validate_trusted_fake_ip_cidrs(cls, values: list[str]) -> list[str]:
from opensquilla.tools.ssrf import validate_trusted_fake_ip_cidrs
return validate_trusted_fake_ip_cidrs(values)
class PermissionsConfig(BaseModel):
"""Default owner permission posture for local/operator turns."""
model_config = ConfigDict(extra="forbid")
default_mode: Literal["off", "on", "bypass", "full"] = "off"
class TaskRuntimeConfig(BaseModel):
"""Server-side task-runtime queue settings."""
max_concurrency: int = Field(default=8, ge=1)
max_pending_per_session: int = Field(default=64, ge=1)
# Per-channel-adapter in-flight semaphore (separate from
# task_runtime._global_sem). Configured here so OPENSQUILLA_CHANNEL_INFLIGHT_CAP
# has a stable env name regardless of channel adapter wiring.
channel_inflight_cap: int = Field(default=8, ge=1)
# Hard ceiling on how long a single turn may hold the OUTER per-session
# lock before the dead-turn breaker fires. ``None`` keeps the historical
# behaviour (no breaker, jam tolerated).
turn_hard_deadline_s: float | None = Field(default=None, gt=0)
# Global default policy when ``max_pending_per_session`` is exceeded.
# ``reject_newest`` preserves legacy reject-on-overflow. ``drop_oldest``
# evicts the oldest QUEUED pending task on the session and accepts the
# new turn — useful for noisy realtime channels where the freshest
# message matters more than the queued backlog.
pending_overflow_policy: str = Field(default="reject_newest")
# Per-channel override map. Keys are channel ids (e.g. ``"feishu"``),
# values are policy strings. Channels not listed fall back to
# ``pending_overflow_policy``. Empty dict by default — no channel is
# tuned independently.
pending_overflow_policy_per_channel: dict[str, str] = Field(default_factory=dict)
# Stream relay coalescing window. Consecutive text deltas inside a single
# window are concatenated into one chunk before being yielded to the
# channel adapter's ``send_streaming``. ``0`` (default) preserves the
# historical one-chunk-per-delta behaviour. Operators tune this for
# adapters that incur a per-call cost on ``send_streaming`` updates.
stream_relay_coalesce_ms: float = Field(default=0.0, ge=0)
# Hard cap on the size of a coalesced chunk. ``0`` (default) keeps the
# historical behaviour — used together with
# ``stream_relay_coalesce_ms`` to enable batching.
stream_relay_coalesce_chars: int = Field(default=0, ge=0)
@field_validator("pending_overflow_policy")
@classmethod
def _validate_overflow_policy(cls, value: str) -> str:
from opensquilla.gateway.task_runtime import PendingOverflowPolicy
try:
PendingOverflowPolicy(value)
except ValueError as exc:
valid = ", ".join(member.value for member in PendingOverflowPolicy)
raise ValueError(
f"pending_overflow_policy must be one of {{{valid}}}"
) from exc
return value
@field_validator("pending_overflow_policy_per_channel")
@classmethod
def _validate_per_channel_policy(cls, value: dict[str, str]) -> dict[str, str]:
from opensquilla.gateway.task_runtime import PendingOverflowPolicy
valid = ", ".join(member.value for member in PendingOverflowPolicy)
for channel, policy in value.items():
try:
PendingOverflowPolicy(policy)
except ValueError as exc:
raise ValueError(
f"pending_overflow_policy_per_channel[{channel!r}] "
f"must be one of {{{valid}}}"
) from exc
return value
# Pre-tokenrhythm built-in defaults. Configs authored while openrouter was
# the built-in default may rely on them without naming a provider, so the
# load-time resolution in ``GatewayConfig._resolve_default_llm_provider``
# restores this trio whenever such a config is detected.
LEGACY_DEFAULT_LLM_PROVIDER = "openrouter"
LEGACY_DEFAULT_LLM_MODEL = "deepseek/deepseek-v4-pro"
LEGACY_DEFAULT_LLM_BASE_URL = "https://openrouter.ai/api/v1"
TOKENRHYTHM_DEFAULT_LLM_PROVIDER = "tokenrhythm"
TOKENRHYTHM_DEFAULT_LLM_BASE_URL = "https://tokenrhythm.studio/v1"
class LlmProviderConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_LLM_")
provider: str = "tokenrhythm"
model: str = "deepseek-v4-pro-0813"
api_key: str = ""
api_key_env: str = ""
base_url: str = "https://tokenrhythm.studio/v1"
proxy: str = "" # explicit HTTP proxy URL (e.g. http://127.0.0.1:7890)
max_tokens: int = 0 # 0 = auto-resolve from model catalog; >0 = explicit override
# 0 = auto-resolve from model catalog; >0 = explicit context-window override
# in tokens. Drives the provider-context budget ladder and context usage
# reporting for models the catalog does not know (e.g. direct DashScope
# model ids that never appear in the OpenRouter catalog fetch).
context_window_tokens: int = 0
temperature: float | None = None
top_p: float | None = None
# Optional global thinking level: off|minimal|low|medium|high|xhigh|adaptive.
# When unset, squilla_router may suggest thinking for selected tiers.
# Accepts both "thinking" and "thinking_level" spellings in TOML and env
# (OPENSQUILLA_LLM_THINKING / OPENSQUILLA_LLM_THINKING_LEVEL) for parity
# with squilla_router.tiers field names. model_dump emits only "thinking".
thinking: str | None = Field(
default=None,
validation_alias=AliasChoices(
"thinking",
"thinking_level",
"OPENSQUILLA_LLM_THINKING",
"OPENSQUILLA_LLM_THINKING_LEVEL",
),
)
# Explicit provider-request proof budget in characters. 0 = derive from the
# context-budget ladder (window minus output+thinking reserve, times the
# overflow threshold). A positive value bypasses that derivation and feeds
# request-proof projection directly, so operators can size provider payloads
# for models whose output reserve would otherwise dominate the window.
provider_request_proof_max_chars: int = 0
# OpenRouter-only: map model id -> upstream provider name. Mapped models
# send provider.order=[name] so the provider is preferred without disabling
# OpenRouter fallback.
provider_routing: dict[str, str] = Field(default_factory=dict)
@model_validator(mode="after")
def _normalize_direct_deepseek_model(self) -> LlmProviderConfig:
if str(self.provider or "").strip().lower() != "deepseek":
return self
aliases = {
"deepseek/deepseek-v4-flash": "deepseek-v4-flash",
"deepseek/deepseek-v4-pro": "deepseek-v4-pro",
}
model = str(self.model or "").strip()
if model in aliases:
self.model = aliases[model]
return self
# Backward-compatible alias for older imports. New configs do not use these as
# defaults; they are only recognized as the old OpenRouter preset payload.
DEFAULT_LLM_ENSEMBLE_MODEL_OPTIONS = LEGACY_OPENROUTER_MODEL_OPTIONS
def _default_llm_ensemble_model_options() -> list[str]:
"""Legacy model_options default is intentionally empty for new configs."""
return []
# Candidate roles for the custom B5 lineup. "proposer" drafts independently;
# "aggregator" fuses those drafts and produces the final answer.
# Backward-compatible symbol for callers that imported the old gateway table.
LLM_ENSEMBLE_CANDIDATE_ROLES = ENSEMBLE_CANDIDATE_ROLES
class LlmEnsembleCandidateConfig(BaseModel):
provider: str
model: str
source: Literal["custom", "legacy_model_options"] = "custom"
enabled: bool = True
# Released advisory aliases and unknown values coerce to "proposer"
# instead of failing validation, so old or hand-edited configs still boot.
# Strict role/lineup checks live on the RPC save path (upsert mutation).
role: str = "proposer"
# Per-candidate thinking level override: off|minimal|low|medium|high|xhigh.
# Coerced to "" (inherit from turn config) on invalid input so a hand-edited
# config never blocks gateway boot, matching the role field policy above.
thinking_level: str = ""
@field_validator("provider", "model", mode="before")
@classmethod
def _strip_required_text(cls, value: object) -> str:
return str(value or "").strip()
@field_validator("role", mode="before")
@classmethod
def _normalize_role(cls, value: object) -> str:
normalized = str(value or "").strip().lower()
return normalized if normalized in ENSEMBLE_CANDIDATE_ROLES else "proposer"
@field_validator("thinking_level", mode="before")
@classmethod
def _normalize_thinking_level(cls, value: object) -> str:
normalized = str(value or "").strip().lower()
if not normalized:
return ""
valid = {"off", "minimal", "low", "medium", "high", "xhigh", "adaptive"}
return normalized if normalized in valid else ""
@model_validator(mode="after")
def _validate_candidate(self) -> LlmEnsembleCandidateConfig:
if not self.provider:
raise ValueError("llm_ensemble.candidates.provider must be non-empty")
if not self.model:
raise ValueError("llm_ensemble.candidates.model must be non-empty")
self.provider = self.provider.lower()
return self
class LlmEnsembleConfig(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="OPENSQUILLA_LLM_ENSEMBLE_",
env_nested_delimiter="__",
extra="ignore",
)
# Model router is the default routing surface. The legacy static selection
# value remains below for read compatibility, but it is dormant until an
# operator explicitly enables the ensemble surface.
enabled: bool = False
mode: Literal["b5_fusion"] = "b5_fusion"
selection_mode: EnsembleSelectionMode = cast(
EnsembleSelectionMode,
STATIC_OPENROUTER_B5_SELECTION_MODE,
)
# Expose tool schemas to proposers as advisory vocabulary only. Proposer
# output is never dispatched; only the aggregator owns an executable tool
# boundary.
proposer_tools: bool = False
min_successful_proposers: int = Field(default=1, ge=1)
# Optional quality target above the minimum floor. When set, proposer
# collection keeps waiting for this many successful drafts; if the target
# becomes unreachable, the turn may still aggregate once the minimum
# floor is satisfied.
target_successful_proposers: int | None = Field(default=None, ge=1)
# Number of in-place retries after the initial proposer request. Zero
# preserves the historical single-attempt behavior.
proposer_max_retries: int = Field(default=0, ge=0, le=10)
all_failed_policy: Literal["fallback_single", "error"] = "fallback_single"
model_options: list[str] = Field(default_factory=_default_llm_ensemble_model_options)
candidates: list[LlmEnsembleCandidateConfig] = Field(default_factory=list)
candidate_max_chars: int = Field(default=24_000, ge=0)
proposer_timeout_seconds: float = Field(default=3600.0, gt=0.0)
aggregator_timeout_seconds: float = Field(default=3600.0, gt=0.0)
# Deprecated read-compatibility field. The ensemble runtime intentionally
# ignores it and relies on the per-call proposer/aggregator/fixed-provider
# idle timeouts instead.
total_timeout_seconds: float | None = Field(default=None, ge=0.0)
shuffle_candidates: bool = True
record_candidates: bool = False
@model_validator(mode="after")
def _validate_model_options(self) -> LlmEnsembleConfig:
model_options: list[str] = []
seen_options: set[str] = set()
for model in self.model_options:
normalized = str(model or "").strip()
if not normalized or normalized in seen_options:
continue
seen_options.add(normalized)
model_options.append(normalized)
self.model_options = model_options
return self
@model_validator(mode="after")
def _validate_success_targets(self) -> LlmEnsembleConfig:
if (
self.target_successful_proposers is not None
and self.target_successful_proposers < self.min_successful_proposers
):
raise ValueError(
"llm_ensemble.target_successful_proposers cannot be lower than "
"llm_ensemble.min_successful_proposers"
)
if (
self.selection_mode
in STATIC_B5_SELECTION_MODES
and self.target_successful_proposers is not None
and self.target_successful_proposers > 4
):
raise ValueError(
"llm_ensemble.target_successful_proposers cannot exceed the "
"static B5 proposer count (4)"
)
return self
@model_validator(mode="after")
def _validate_custom_b5_lineup(self) -> LlmEnsembleConfig:
"""Bound the explicit custom_b5 lineup.
Only the custom mode is checked: static profiles carry fixed lineups
and router_dynamic selects members per turn, so neither can exceed the
cap. The aggregator is structural — at most one candidate may carry
the role; the last enabled proposer count must stay within
[CUSTOM_B5_MIN_PROPOSERS, CUSTOM_B5_MAX_PROPOSERS]. Disabled rows are
kept (read compatibility) but never counted.
"""
aggregators = [
candidate
for candidate in self.candidates
if candidate.enabled and candidate.role == "aggregator"
]
if len(aggregators) > 1:
raise ValueError(
"llm_ensemble.candidates may mark at most one enabled "
"candidate with role='aggregator'"
)
if self.selection_mode != CUSTOM_B5_SELECTION_MODE:
return self
proposers = [
candidate
for candidate in self.candidates
if candidate.enabled and candidate.role != "aggregator"
]
if len(proposers) < CUSTOM_B5_MIN_PROPOSERS:
raise ValueError(
"llm_ensemble.selection_mode='custom_b5' needs at least "
f"{CUSTOM_B5_MIN_PROPOSERS} enabled proposer candidates"
)
if len(proposers) > CUSTOM_B5_MAX_PROPOSERS:
raise ValueError(
"llm_ensemble.selection_mode='custom_b5' allows at most "
f"{CUSTOM_B5_MAX_PROPOSERS} enabled proposer candidates"
)
# Total per-turn call ceiling (proposers + aggregator). Today k is
# fixed at 1 per member; the ceiling still guards a future k surface.
if len(proposers) + 1 > CUSTOM_B5_MAX_TOTAL_CALLS:
raise ValueError(
"llm_ensemble custom_b5 lineup exceeds "
f"{CUSTOM_B5_MAX_TOTAL_CALLS} total per-turn calls"
)
if self.min_successful_proposers > len(proposers):
raise ValueError(
"llm_ensemble.min_successful_proposers cannot exceed the "
f"custom_b5 proposer count ({len(proposers)})"
)
if (
self.target_successful_proposers is not None
and self.target_successful_proposers > len(proposers)
):
raise ValueError(
"llm_ensemble.target_successful_proposers cannot exceed the "
f"custom_b5 proposer count ({len(proposers)})"
)
return self
STATIC_OPENROUTER_B5_MIN_AGENT_STREAM_IDLE_TIMEOUT_SECONDS = 1200.0
STATIC_OPENROUTER_B5_MIN_WEBUI_STREAM_IDLE_GRACE_SECONDS = 1260.0
def _non_negative_float(value: Any, default: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
parsed = default
return max(0.0, parsed)
def _configured_static_b5_selection_modes(config: Any) -> tuple[str, ...]:
modes: list[str] = []
ensemble_cfg = getattr(config, "llm_ensemble", None)
global_mode = effective_ensemble_selection_mode(config)
if bool(getattr(ensemble_cfg, "enabled", False)) and global_mode in STATIC_B5_SELECTION_MODES:
modes.append(global_mode)
router = getattr(config, "squilla_router", None)
if bool(getattr(router, "enabled", False)):
tier_modes = effective_tier_ensemble_selection_modes(
getattr(router, "tiers", None),
shared_selection_mode=global_mode,
)
modes.extend(
mode for mode in tier_modes.values() if mode in STATIC_B5_SELECTION_MODES
)
return tuple(dict.fromkeys(modes))
def static_b5_ensemble_enabled(config: Any) -> bool:
"""Whether global or tier-managed configuration can run static B5."""
return bool(_configured_static_b5_selection_modes(config))
def static_b5_ensemble_active(config: Any) -> bool:
"""True when a static-B5 profile is enabled *and* resolves a credential.
The stream-idle floors below exist for the real (slow) static-B5
ensembles. A keyless install can never run those members — the wrap is
skipped at turn time — so it keeps the default hang-detection budgets.
The credential check is the shared ensemble-side helper (lazy import;
``provider`` never imports from ``gateway``, so no cycle) and therefore
cannot disagree with the turn-time wrap guard.
"""
selection_modes = _configured_static_b5_selection_modes(config)
if not selection_modes:
return False
from opensquilla.provider.ensemble import static_b5_credential_available
return any(
static_b5_credential_available(
config,
getattr(config, "llm", None),
selection_mode,
)
for selection_mode in selection_modes
)
def _global_static_b5_ensemble_active(config: Any) -> bool:
"""Whether the global ensemble surface will run a static-B5 profile.
Tier-managed fusion only affects turns routed to that tier, so it must not
inflate the gateway-wide stream-idle budget for every other request.
"""
ensemble_cfg = getattr(config, "llm_ensemble", None)
selection_mode = str(getattr(ensemble_cfg, "selection_mode", "") or "")
if not bool(getattr(ensemble_cfg, "enabled", False)):
return False
if selection_mode not in STATIC_B5_SELECTION_MODES:
return False
from opensquilla.provider.ensemble import static_b5_credential_available
return static_b5_credential_available(
config,
getattr(config, "llm", None),
selection_mode,
)
def effective_agent_stream_idle_timeout_seconds(config: Any) -> float:
value = _non_negative_float(
getattr(config, "agent_stream_idle_timeout_seconds", 600.0),
600.0,
)
if _global_static_b5_ensemble_active(config):
value = max(value, STATIC_OPENROUTER_B5_MIN_AGENT_STREAM_IDLE_TIMEOUT_SECONDS)
return value
def effective_webui_stream_idle_grace_seconds(config: Any) -> float:
value = _non_negative_float(
getattr(config, "webui_stream_idle_grace_seconds", 630.0),
630.0,
)
if _global_static_b5_ensemble_active(config):
server_idle = effective_agent_stream_idle_timeout_seconds(config)
value = max(
value,
STATIC_OPENROUTER_B5_MIN_WEBUI_STREAM_IDLE_GRACE_SECONDS,
server_idle + 60.0,
)
return value
# Module-level dedupe state for the legacy ``enabled`` deprecation warning.
# A plain ``bool`` flag guarded by a ``Lock`` makes the check-and-set atomic
# across concurrent constructors; ``threading.Event`` is *not* atomic for
# the test-then-set pattern (two threads can both observe is_set()==False
# before either calls set()), which would emit duplicate warnings.
_LEGACY_ENABLED_WARN_LOCK = threading.Lock()
_LEGACY_ENABLED_WARNED = False
# Pydantic-style truthy/falsy string sets (case-insensitive). Mirrors the
# loose ``bool`` validator semantics so the migrated ``enabled`` key behaves
# the way pydantic-settings v2 would have validated it before the field was
# removed.
_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on", "y", "t"})
_FALSY_STRINGS = frozenset({"0", "false", "no", "off", "n", "f"})
def _coerce_legacy_enabled(value: Any) -> bool:
"""Strict bool coercion for the deprecated ``enabled`` legacy key.
Matches pydantic v2 loose-bool semantics for strings (case-insensitive
accept of ``{1, true, y, yes, on, t}`` / ``{0, false, n, no, off, f}``)
and ints ``0``/``1``. Any other value raises ``ValueError`` so invalid
inputs (e.g. ``"maybe"``) surface as a ``ValidationError`` rather than
being silently mapped to ``mode="off"``.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in _TRUTHY_STRINGS:
return True
if normalized in _FALSY_STRINGS:
return False
elif isinstance(value, int):
# ``bool`` is a subclass of ``int`` so it was handled above; only the
# unambiguous 0/1 ints match pydantic loose bool.
if value == 0:
return False
if value == 1:
return True
raise ValueError(f"prompt_cache.enabled: cannot coerce {value!r} to bool")
class PromptCacheConfig(BaseSettings):
# ``env_prefix`` stays so ``OPENSQUILLA_CACHE_MODE`` continues to bind the
# ``mode`` field. The legacy ``OPENSQUILLA_CACHE_ENABLED`` env var is no
# longer a field — it is probed explicitly in ``__init__`` below and
# routed through the legacy migration validator, because pydantic-
# settings only surfaces env keys that correspond to declared fields
# to ``model_validator(mode='before')``.
model_config = SettingsConfigDict(env_prefix="OPENSQUILLA_CACHE_")
mode: Literal["off", "auto", "on"] = "auto"
def __init__(self, **data: Any) -> None:
# Surface the legacy ``OPENSQUILLA_CACHE_ENABLED`` env var to the
# before-validator. Without this probe the env var would be
# silently dropped after the field was removed from the model.
if "enabled" not in data:
legacy_env = os.environ.get("OPENSQUILLA_CACHE_ENABLED")
if legacy_env is not None and legacy_env != "":
data["enabled"] = legacy_env
super().__init__(**data)
@model_validator(mode="before")
@classmethod
def _migrate_legacy_enabled(cls, data: Any) -> Any:
"""Map the deprecated ``enabled`` key onto ``mode`` (one warn/proc)."""
if not isinstance(data, dict):
return data
if "enabled" in data and "mode" not in data:
legacy = _coerce_legacy_enabled(data.pop("enabled"))
data["mode"] = "on" if legacy else "off"
# Atomic check-and-set under the lock so concurrent constructors
# cannot both win the dedupe race. The actual ``warnings.warn``
# call is performed *outside* the lock to avoid holding it across
# user-supplied warning filters/handlers (which could deadlock or
# be slow).
global _LEGACY_ENABLED_WARNED
with _LEGACY_ENABLED_WARN_LOCK:
should_warn = not _LEGACY_ENABLED_WARNED
if should_warn:
_LEGACY_ENABLED_WARNED = True
if should_warn:
warnings.warn(
f"prompt_cache.enabled is deprecated; use prompt_cache.mode "
f"({{off|auto|on}}). Mapped enabled={legacy!r} -> "
f"mode={data['mode']!r}. Removal target: 0.next+2.",
DeprecationWarning,
stacklevel=2,
)
elif "enabled" in data:
# Explicit ``mode`` wins; drop legacy silently.
data.pop("enabled")
return data
@property
def effective_mode(self) -> Literal["off", "auto", "on"]:
"""Return the product-facing prompt-cache mode.
``mode`` is the single source of truth; legacy ``enabled`` keys
are migrated by ``_migrate_legacy_enabled`` before they reach
this property.
"""
return self.mode
class DreamConfig(BaseModel):
"""Per-agent Dream consolidation cron configuration."""
model_config = ConfigDict(extra="forbid")
enabled: bool = False
interval_h: int = Field(default=24, ge=1)
cron: str | None = None # e.g. "0 3 * * *"; overrides interval_h when set
max_batch_size: int = Field(default=20, ge=1)
max_iterations: int = Field(default=15, ge=1)
min_batch_size: int = Field(default=1, ge=1)
preview_mode: bool = True
auto_schedule: bool = False
input_slimming: Literal["off", "shadow", "on"] = "off"
memory_max_chars: int = Field(default=12_000, ge=0)
candidate_file_max_chars: int = Field(default=4_000, ge=0)
candidate_total_max_chars: int = Field(default=24_000, ge=0)
fallback_total_max_chars: int = Field(default=80_000, ge=0)
evidence_min_score: float = Field(default=0.55, ge=0.0, le=1.0)
evidence_min_seen_count: int = Field(default=1, ge=1)
evidence_negative_recurrence_threshold: int = Field(default=2, ge=1)
evidence_curated_writes_enabled: bool = True
evidence_quarantine_enabled: bool = True
class SafetyConfig(BaseModel):
"""Prompt-ingress safety controls."""
wrap_untrusted_workspace: bool = True
injection_scan_mode: Literal["off", "report", "enforce"] = "report"
class PromptConfig(BaseModel):
"""Prompt-layer feature flags."""
mode: Literal[
"auto",
"full",
"minimal",
"none",
"headless_source_edit",
"headless_repo_coding_scaffold",
] = "auto"
platform_hint_enabled: bool = True
# Opt-in additive "Patch Evidence Protocol" system-prompt section for
# repo-coding/patching sessions. Overridable per run via the
# OPENSQUILLA_PATCH_EVIDENCE_PROTOCOL env var ("on"/"off").
patch_evidence_protocol: bool = False
# Opt-in additive "Reproduction Evidence" system-prompt section plus the
# loop-side finalize-time red-evidence gate (engine.finalize_evidence_gate).
# Overridable per run via the OPENSQUILLA_FINALIZE_EVIDENCE_GATE env var
# ("on"/"off").
finalize_evidence_gate: bool = False
# Opt-in switch restoring the earlier compact "Tool Call Style" and
# "Reply Guidelines" system-prompt directives (single-line narration,
# concise replies) for deployments tuned against the previous wording.
# Overridable per run via the OPENSQUILLA_LEGACY_PROMPT_STYLE env var
# ("on"/"off"). Off keeps the current wording unchanged.
legacy_prompt_style: bool = False
MemoryEmbeddingProvider = Literal[
"auto",
"none",
"local",
"openai",
"openai-compatible",
"ollama",
]
class MemoryEmbeddingLocalConfig(BaseModel):
"""Local memory embedding settings."""
onnx_dir: str | None = None
class MemoryEmbeddingRemoteConfig(BaseModel):
"""OpenAI-compatible remote memory embedding settings."""
api_key: str | None = None
api_key_env: str | None = None
base_url: str | None = None
headers: dict[str, str] = Field(default_factory=dict)
model: str | None = None
dimensions: int | None = Field(default=None, ge=1)
class MemoryEmbeddingOllamaConfig(BaseModel):
"""Ollama memory embedding settings."""
base_url: str | None = None
model: str | None = None
class MemoryEmbeddingConfig(BaseModel):
"""Embedding provider selection for the stable memory search index.
``provider`` is the canonical field. ``mode`` and the flat
``api_key``/``base_url``/``model`` fields remain for older configs.
Concrete ``provider`` values win over legacy ``mode``. The default
``provider="auto"`` still honors legacy ``mode`` so old configs keep
round-tripping safely.
"""
provider: MemoryEmbeddingProvider = "auto"
mode: MemoryEmbeddingProvider | None = None
model: str | None = None
api_key: str | None = None
base_url: str | None = None
local: MemoryEmbeddingLocalConfig = Field(default_factory=MemoryEmbeddingLocalConfig)
remote: MemoryEmbeddingRemoteConfig = Field(default_factory=MemoryEmbeddingRemoteConfig)
ollama: MemoryEmbeddingOllamaConfig = Field(default_factory=MemoryEmbeddingOllamaConfig)
@property
def requested_provider(self) -> MemoryEmbeddingProvider:
if self.provider == "auto" and self.mode:
return self.mode
return self.provider
class MemoryCostConfig(BaseModel):