openvideokit 0.1.0__py3-none-any.whl

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 (90) hide show
  1. openvideokit/__init__.py +7 -0
  2. openvideokit/__main__.py +6 -0
  3. openvideokit/ai/__init__.py +9 -0
  4. openvideokit/ai/config.py +121 -0
  5. openvideokit/ai/context.py +64 -0
  6. openvideokit/ai/events.py +78 -0
  7. openvideokit/ai/graph.py +35 -0
  8. openvideokit/ai/llm.py +87 -0
  9. openvideokit/ai/monitor/__init__.py +18 -0
  10. openvideokit/ai/monitor/config.py +80 -0
  11. openvideokit/ai/monitor/observer.py +462 -0
  12. openvideokit/ai/monitor/sinks.py +605 -0
  13. openvideokit/ai/monitor/types.py +142 -0
  14. openvideokit/ai/ops.py +205 -0
  15. openvideokit/ai/prompts/__init__.py +97 -0
  16. openvideokit/ai/prompts/caption_rules.py +21 -0
  17. openvideokit/ai/prompts/html_contract.py +30 -0
  18. openvideokit/ai/prompts/model.py +42 -0
  19. openvideokit/ai/prompts/project_context.py +58 -0
  20. openvideokit/ai/prompts/role.py +107 -0
  21. openvideokit/ai/prompts/safety.py +20 -0
  22. openvideokit/ai/prompts/templates.py +68 -0
  23. openvideokit/ai/prompts/tools.py +75 -0
  24. openvideokit/ai/prompts/voice_rules.py +44 -0
  25. openvideokit/ai/providers.py +103 -0
  26. openvideokit/ai/sandbox.py +272 -0
  27. openvideokit/ai/server.py +341 -0
  28. openvideokit/ai/tools/__init__.py +88 -0
  29. openvideokit/ai/tools/_lint.py +159 -0
  30. openvideokit/ai/tools/_registry.py +95 -0
  31. openvideokit/ai/tools/_template.py +108 -0
  32. openvideokit/ai/tools/_voicelist.py +33 -0
  33. openvideokit/ai/tools/_web_async.py +53 -0
  34. openvideokit/ai/tools/add_many_slides.py +145 -0
  35. openvideokit/ai/tools/add_slide.py +151 -0
  36. openvideokit/ai/tools/apply_template.py +83 -0
  37. openvideokit/ai/tools/copy_slide_html.py +96 -0
  38. openvideokit/ai/tools/duplicate_slide.py +32 -0
  39. openvideokit/ai/tools/grep_slides.py +48 -0
  40. openvideokit/ai/tools/list_files.py +39 -0
  41. openvideokit/ai/tools/list_slides.py +44 -0
  42. openvideokit/ai/tools/list_templates.py +57 -0
  43. openvideokit/ai/tools/list_voices.py +84 -0
  44. openvideokit/ai/tools/read_file.py +61 -0
  45. openvideokit/ai/tools/read_many_files.py +58 -0
  46. openvideokit/ai/tools/read_templates.py +115 -0
  47. openvideokit/ai/tools/remove_many_slides.py +72 -0
  48. openvideokit/ai/tools/reorder_slides.py +46 -0
  49. openvideokit/ai/tools/run_python_sandbox.py +48 -0
  50. openvideokit/ai/tools/set_caption_settings.py +63 -0
  51. openvideokit/ai/tools/set_caption_style.py +35 -0
  52. openvideokit/ai/tools/set_field.py +42 -0
  53. openvideokit/ai/tools/set_many_durations.py +74 -0
  54. openvideokit/ai/tools/set_many_fields.py +77 -0
  55. openvideokit/ai/tools/set_many_slides_html.py +80 -0
  56. openvideokit/ai/tools/set_many_voiceovers.py +92 -0
  57. openvideokit/ai/tools/set_voiceover.py +107 -0
  58. openvideokit/ai/tools/tree.py +155 -0
  59. openvideokit/ai/tools/web_fetch.py +126 -0
  60. openvideokit/ai/tools/web_search.py +68 -0
  61. openvideokit/app.py +207 -0
  62. openvideokit/assets.py +254 -0
  63. openvideokit/captions.py +425 -0
  64. openvideokit/chats.py +160 -0
  65. openvideokit/cli.py +784 -0
  66. openvideokit/composition.py +177 -0
  67. openvideokit/config.py +153 -0
  68. openvideokit/events.py +61 -0
  69. openvideokit/gsap_validator.py +290 -0
  70. openvideokit/mcp/__init__.py +26 -0
  71. openvideokit/mcp/apply_op.py +318 -0
  72. openvideokit/mcp/persist.py +247 -0
  73. openvideokit/mcp/server.py +262 -0
  74. openvideokit/rendering.py +649 -0
  75. openvideokit/routes.py +745 -0
  76. openvideokit/seed.py +182 -0
  77. openvideokit/stamp.py +45 -0
  78. openvideokit/store.py +579 -0
  79. openvideokit/templates.py +216 -0
  80. openvideokit/templates_preview.py +272 -0
  81. openvideokit/tts_queue.py +193 -0
  82. openvideokit/user_config.py +363 -0
  83. openvideokit/validation.py +104 -0
  84. openvideokit/voiceover.py +362 -0
  85. openvideokit/voices.py +180 -0
  86. openvideokit/watcher.py +97 -0
  87. openvideokit-0.1.0.dist-info/METADATA +26 -0
  88. openvideokit-0.1.0.dist-info/RECORD +90 -0
  89. openvideokit-0.1.0.dist-info/WHEEL +4 -0
  90. openvideokit-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,7 @@
1
+ """OpenVideoKit — installable CLI + SSR/agentic server.
2
+
3
+ `pip install openvideokit` → `ovk serve` runs the FastAPI server on :8000
4
+ (serves the editor SPA + stamped HF compositions for <hyperframes-player>).
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """`python -m openvideokit` → `ovk`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,9 @@
1
+ """OpenVideoKit AI subsystem — a LangGraph agent that edits the project by
2
+ emitting ``EditOp`` proposals the frontend ``EditBus`` dispatches on Accept.
3
+
4
+ See ``docs/ai.md`` for the full implementation contract.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ["config", "ops", "events", "context", "llm", "graph", "server"]
@@ -0,0 +1,121 @@
1
+ """Runtime configuration for the AI subsystem.
2
+
3
+ Three-tier resolver (highest wins):
4
+ 1. Real env vars / .env (already merged into ``os.environ`` by python-dotenv
5
+ at the top-level ``config.py`` import)
6
+ 2. ``~/.openvideokit/config.toml`` under ``[ai]``
7
+ 3. Hardcoded defaults below
8
+
9
+ Standard OpenAI-compatible env names (``OPENAI_BASE_URL`` / ``OPENAI_API_KEY``)
10
+ so any compliant endpoint works without custom config: OpenAI, OpenRouter
11
+ (``https://openrouter.ai/api/v1``), Ollama (``http://localhost:11434/v1``),
12
+ vLLM, LM Studio.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from ..user_config import load_user_config
20
+
21
+ _USER_CONFIG = load_user_config()
22
+
23
+
24
+ def _resolve(group: str, key: str, env_var: str, default, cast=str):
25
+ raw = os.environ.get(env_var)
26
+ if raw is not None and raw != "":
27
+ try:
28
+ return cast(raw)
29
+ except (TypeError, ValueError):
30
+ pass
31
+ user_val = _USER_CONFIG.get(group, {}).get(key) if isinstance(_USER_CONFIG, dict) else None
32
+ if user_val is not None:
33
+ return user_val
34
+ return default
35
+
36
+
37
+ def _resolve_bool(group: str, key: str, env_var: str, default: bool) -> bool:
38
+ raw = os.environ.get(env_var, "").lower()
39
+ if raw in ("1", "true", "yes", "on"):
40
+ return True
41
+ if raw in ("0", "false", "no", "off"):
42
+ return False
43
+ user_val = _USER_CONFIG.get(group, {}).get(key) if isinstance(_USER_CONFIG, dict) else None
44
+ if isinstance(user_val, bool):
45
+ return user_val
46
+ return default
47
+
48
+
49
+ # OpenAI-compatible endpoint. Default assumes openai itself.
50
+ OPENAI_BASE_URL = _resolve(
51
+ "ai", "openai_base_url", "OPENAI_BASE_URL", "https://api.openai.com/v1"
52
+ )
53
+
54
+ # API key. Required to use AI; an empty key yields a graceful ``error`` event
55
+ # from the agent runner (no crash).
56
+ OPENAI_API_KEY = _resolve("ai", "openai_api_key", "OPENAI_API_KEY", "")
57
+
58
+ # Default (Tier-1) chat model id — whatever the chosen endpoint accepts.
59
+ OVK_AI_MODEL = _resolve("ai", "model", "OVK_AI_MODEL", "gpt-5.4-nano")
60
+
61
+ # Reserved for future per-tool coding-model routing (Tier-2 / set_slide_html).
62
+ # Falls back to OVK_AI_MODEL when empty.
63
+ OVK_AI_TIER2_MODEL = _resolve(
64
+ "ai", "tier2_model", "OVK_AI_TIER2_MODEL", OVK_AI_MODEL
65
+ )
66
+
67
+ # Sampling temperature for the agent.
68
+ OVK_AI_TEMPERATURE = _resolve(
69
+ "ai", "temperature", "OVK_AI_TEMPERATURE", 0.3, cast=float
70
+ )
71
+
72
+ # Cap on agent tool-calling steps per turn (bounds cost + latency).
73
+ OVK_AI_MAX_STEPS = _resolve("ai", "max_steps", "OVK_AI_MAX_STEPS", 50, cast=int)
74
+
75
+ # Reasoning effort for reasoning-capable models ("low" / "medium" / "high").
76
+ # Empty = don't send (correct for non-reasoning models like gpt-4o-mini).
77
+ # Only set this if OVK_AI_MODEL is a reasoning model (gpt-5, o1, o3, gpt-oss, …).
78
+ OVK_AI_REASONING_EFFORT = _resolve(
79
+ "ai", "reasoning_effort", "OVK_AI_REASONING_EFFORT", ""
80
+ )
81
+
82
+ # Web tools (web_search + web_fetch). The tools are always registered so the
83
+ # agent always knows they exist; this flag controls runtime execution. When
84
+ # false, calls return a "disabled" notice the agent relays to the user.
85
+ OVK_ENABLE_WEB = _resolve_bool("ai", "enable_web", "OVK_ENABLE_WEB", True)
86
+
87
+
88
+ def is_web_enabled() -> bool:
89
+ """True iff the flag is on AND both required libs import successfully.
90
+
91
+ Tools stay registered regardless; this gate decides whether a call actually
92
+ hits the network or returns a disabled notice.
93
+ """
94
+ if not OVK_ENABLE_WEB:
95
+ return False
96
+ try:
97
+ import crawl4ai # noqa: F401
98
+ import ddgs # noqa: F401
99
+
100
+ return True
101
+ except ImportError:
102
+ return False
103
+
104
+
105
+ def web_disabled_reason() -> str:
106
+ """Human-readable reason the web tools refused — used as their return value."""
107
+ if not OVK_ENABLE_WEB:
108
+ return (
109
+ "Web tools are disabled. Set OVK_ENABLE_WEB=true in .env or "
110
+ "[ai].enable_web=true in ~/.openvideokit/config.toml to enable "
111
+ "web_search/web_fetch."
112
+ )
113
+ return (
114
+ "Web tools are unavailable: required packages not installed "
115
+ "(run `uv sync`)."
116
+ )
117
+
118
+
119
+ def is_configured() -> bool:
120
+ """True iff an API key is present (i.e. AI is usable)."""
121
+ return bool(OPENAI_API_KEY)
@@ -0,0 +1,64 @@
1
+ """OKVContext — per-request context bound into every tool via closure.
2
+
3
+ Carries the read-only project snapshot the tools validate against + the ids
4
+ the agent's read tools resolve paths through. The agent itself is stateless
5
+ per request; this is the only per-request state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class Pin:
17
+ """A user-pinned reference injected into the prompt as context."""
18
+
19
+ kind: str # "slide" | "field" | "asset"
20
+ value: str # slideId / "slideId.fieldId" / asset ref
21
+
22
+
23
+ @dataclass
24
+ class OVKContext:
25
+ project_id: str
26
+ project: dict[str, Any] # the bundle: {root, slides, slideHtml}
27
+ active_slide_id: str | None = None
28
+ pins: list[Pin] = field(default_factory=list)
29
+
30
+ # ── Convenience accessors ────────────────────────────────────────────
31
+
32
+ @property
33
+ def root(self) -> dict[str, Any]:
34
+ return self.project.get("root", {})
35
+
36
+ @property
37
+ def slides(self) -> dict[str, Any]:
38
+ return self.project.get("slides", {})
39
+
40
+ @property
41
+ def slide_ids(self) -> list[str]:
42
+ return list(self.root.get("slides", []))
43
+
44
+ @property
45
+ def project_dir(self) -> Path:
46
+ # Delegate to store._project_dir so the project_id regex gate (the
47
+ # primary path-safety defense) fires here too — tools infer
48
+ # project_id from the chat URL, and MCP will pass it as a tool arg.
49
+ from ..store import _project_dir
50
+
51
+ return _project_dir(self.project_id)
52
+
53
+ def slide_dir(self, slide_id: str) -> Path:
54
+ # Delegate to store._slide_dir so slide_id is validated too (not just
55
+ # project_id). Raw concat here would bypass the path-safety gate.
56
+ from ..store import _slide_dir
57
+
58
+ return _slide_dir(self.project_id, slide_id)
59
+
60
+ def slide_exists(self, slide_id: str) -> bool:
61
+ return slide_id in self.slides
62
+
63
+ def slide_html(self, slide_id: str) -> str:
64
+ return self.project.get("slideHtml", {}).get(slide_id, "")
@@ -0,0 +1,78 @@
1
+ """AIStreamEvent — the SSE event contract between the Python agent and the
2
+ frontend ``AIDock`` / ``HttpSseProvider``.
3
+
4
+ Serialized as SSE ``data: <json>\\n\\n`` lines by :func:`event_to_sse`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Literal, TypedDict
11
+
12
+
13
+ class TokenEvent(TypedDict):
14
+ type: Literal["token"]
15
+ text: str
16
+
17
+
18
+ class ThinkingEvent(TypedDict):
19
+ type: Literal["thinking"]
20
+ text: str
21
+
22
+
23
+ class ToolStartEvent(TypedDict):
24
+ type: Literal["tool_start"]
25
+ tool: str
26
+ args: dict[str, Any]
27
+
28
+
29
+ class ToolEndEvent(TypedDict):
30
+ type: Literal["tool_end"]
31
+ tool: str
32
+ ok: bool
33
+ result: Any
34
+
35
+
36
+ class ProposalEvent(TypedDict):
37
+ type: Literal["proposal"]
38
+ edit: dict[str, Any] # EditProposalPayload (see ops.py)
39
+
40
+
41
+ class DoneEvent(TypedDict):
42
+ type: Literal["done"]
43
+
44
+
45
+ class ErrorEvent(TypedDict):
46
+ type: Literal["error"]
47
+ message: str
48
+
49
+
50
+ AIStreamEvent = (
51
+ TokenEvent
52
+ | ThinkingEvent
53
+ | ToolStartEvent
54
+ | ToolEndEvent
55
+ | ProposalEvent
56
+ | DoneEvent
57
+ | ErrorEvent
58
+ )
59
+
60
+
61
+ def event_to_sse(event: AIStreamEvent) -> str:
62
+ """Serialize an event to one SSE ``data:`` block (with trailing blank line)."""
63
+ return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
64
+
65
+
66
+ def parse_sse(block: str) -> AIStreamEvent | None:
67
+ """Parse one SSE ``data: ...`` block back into an event dict.
68
+
69
+ Returns ``None`` for keepalive/comments. Used by tests for round-trip.
70
+ """
71
+ line = block.strip()
72
+ if not line or line.startswith(":"):
73
+ return None
74
+ if line.startswith("data:"):
75
+ line = line[5:].strip()
76
+ if not line:
77
+ return None
78
+ return json.loads(line)
@@ -0,0 +1,35 @@
1
+ """LangGraph agent — a ReAct agent over the OVK tool set.
2
+
3
+ Uses ``langchain.agents.create_agent`` (the non-deprecated successor to
4
+ ``langgraph.prebuilt.create_react_agent``). The agent is stateless per request;
5
+ ``build_tools(ctx)`` binds the project context into every tool via closure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from langchain_core.language_models import BaseChatModel
14
+
15
+ from .context import OVKContext
16
+
17
+ from .prompts import build_system_prompt
18
+ from .tools import build_tools
19
+
20
+
21
+ def build_agent(model: BaseChatModel, ctx: OVKContext):
22
+ """Compile the ReAct agent for one request.
23
+
24
+ The system prompt is assembled from the modular ``ai/prompts/`` sections,
25
+ including the auto-generated tool list (zero drift vs the real tools).
26
+ """
27
+ from langchain.agents import create_agent
28
+
29
+ tools = build_tools(ctx)
30
+ system_prompt = build_system_prompt(ctx)
31
+ return create_agent(
32
+ model=model,
33
+ tools=tools,
34
+ system_prompt=system_prompt,
35
+ )
openvideokit/ai/llm.py ADDED
@@ -0,0 +1,87 @@
1
+ """LLM factory — builds the OpenAI-compatible chat model from env.
2
+
3
+ A single ``ChatOpenAI`` configured via ``OPENAI_BASE_URL`` / ``OPENAI_API_KEY``
4
+ covers OpenAI, OpenRouter, Requesty, native DeepSeek, Ollama, vLLM, and LM
5
+ Studio. Provider-specific reasoning/thinking handling lives in
6
+ ``providers.py``; this module just wires its output into ``ChatOpenAI``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # ── Monkey-patch: preserve reasoning/thinking tokens ───────────────────
12
+ # langchain-openai's _convert_delta_to_message_chunk drops the streaming
13
+ # reasoning fields before they reach additional_kwargs. We wrap it to copy
14
+ # whatever the provider emits into additional_kwargs["reasoning_content"],
15
+ # which _extract_text_and_thinking in server.py already reads.
16
+ import langchain_openai.chat_models.base as _lc_base # noqa: E402
17
+ from langchain_openai import ChatOpenAI
18
+
19
+ from . import config
20
+ from .providers import (
21
+ APP_HEADERS,
22
+ detect_provider,
23
+ extract_thinking,
24
+ reasoning_request,
25
+ )
26
+
27
+ _orig_convert = _lc_base._convert_delta_to_message_chunk
28
+
29
+
30
+ def _patched_convert(_dict, default_class): # type: ignore[no-untyped-def]
31
+ chunk = _orig_convert(_dict, default_class)
32
+ reasoning = extract_thinking(_dict)
33
+ if reasoning:
34
+ chunk.additional_kwargs["reasoning_content"] = reasoning
35
+ return chunk
36
+
37
+
38
+ _lc_base._convert_delta_to_message_chunk = _patched_convert
39
+
40
+
41
+ def build_model(
42
+ *,
43
+ model: str = "",
44
+ streaming: bool = True,
45
+ timeout: float | None = None,
46
+ ) -> ChatOpenAI:
47
+ """The default (Tier-1) chat model.
48
+
49
+ ``model`` overrides ``OVK_AI_MODEL`` when given (used by the CLI
50
+ ``--model`` flag). ``timeout`` bounds a single request (None = provider
51
+ default); the CLI diagnostic passes a finite timeout so it can't hang on
52
+ unreachable hosts.
53
+
54
+ ``reasoning_effort`` is forwarded only when ``OVK_AI_REASONING_EFFORT``
55
+ is set — passing it to a non-reasoning model raises, so it's opt-in.
56
+ The exact request shape (OpenRouter ``reasoning`` dict, DeepSeek
57
+ ``thinking`` toggle, etc.) is picked by ``providers.reasoning_request``.
58
+ """
59
+ model_id = model or config.OVK_AI_MODEL
60
+ base_url = config.OPENAI_BASE_URL
61
+
62
+ kwargs: dict = {
63
+ "model": model_id,
64
+ "api_key": config.OPENAI_API_KEY,
65
+ "base_url": base_url,
66
+ "temperature": config.OVK_AI_TEMPERATURE,
67
+ "streaming": streaming,
68
+ "timeout": timeout,
69
+ "default_headers": APP_HEADERS,
70
+ }
71
+
72
+ if config.OVK_AI_REASONING_EFFORT:
73
+ provider = detect_provider(base_url)
74
+ kwargs.update(
75
+ reasoning_request(provider, model_id, config.OVK_AI_REASONING_EFFORT)
76
+ )
77
+
78
+ return ChatOpenAI(**kwargs)
79
+
80
+
81
+ def build_tier2_model(*, streaming: bool = True) -> ChatOpenAI:
82
+ """The Tier-2 (coding) model — reserved for set_slide_html routing.
83
+
84
+ v1 uses the same model as Tier-1 (see docs/ai.md §15); this factory exists
85
+ so a future per-tool routing refinement can swap it independently.
86
+ """
87
+ return build_model(model=config.OVK_AI_TIER2_MODEL, streaming=streaming)
@@ -0,0 +1,18 @@
1
+ """AI monitoring subpackage — observability for the LangGraph agent.
2
+
3
+ Public surface is intentionally tiny: callers import :func:`observe_run` and
4
+ get back either a real :class:`RunObserver` (when ``OVK_AI_MONITORING=true``)
5
+ or a :class:`NullObserver` no-op (the prod default). The route handler then
6
+ calls ``observer.on_event(sse_line)`` for every line streamed by
7
+ ``run_agent`` and ``observer.close()`` at the end. That is the entire
8
+ integration contract.
9
+
10
+ See ``docs/ai-monitoring.md`` for the design, file layout, and how to extend
11
+ the sink set (FileSink today; S3 / OTLP / HTTP planned).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .observer import NullObserver, RunObserver, observe_run
17
+
18
+ __all__ = ["observe_run", "RunObserver", "NullObserver"]
@@ -0,0 +1,80 @@
1
+ """Monitor configuration — self-contained, three-tier resolver.
2
+
3
+ Kept separate from ``ai/config.py`` so the monitor subpackage stays an
4
+ optional, pluggable concern: disabling monitoring removes every import-time
5
+ side effect from this module's callers.
6
+
7
+ Priority (highest wins):
8
+ 1. Real env vars / .env
9
+ 2. ``~/.openvideokit/config.toml`` under ``[ai_monitoring]``
10
+ 3. Hardcoded defaults below
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+
17
+ from ...user_config import load_user_config
18
+
19
+ _USER_CONFIG = load_user_config()
20
+
21
+
22
+ def _resolve_bool(group: str, key: str, env_var: str, default: bool) -> bool:
23
+ raw = os.environ.get(env_var, "").lower()
24
+ if raw in ("1", "true", "yes", "on"):
25
+ return True
26
+ if raw in ("0", "false", "no", "off"):
27
+ return False
28
+ user_val = _USER_CONFIG.get(group, {}).get(key) if isinstance(_USER_CONFIG, dict) else None
29
+ if isinstance(user_val, bool):
30
+ return user_val
31
+ return default
32
+
33
+
34
+ def _resolve_int(group: str, key: str, env_var: str, default: int) -> int:
35
+ raw = os.environ.get(env_var)
36
+ if raw is not None and raw != "":
37
+ try:
38
+ return int(raw)
39
+ except ValueError:
40
+ pass
41
+ user_val = _USER_CONFIG.get(group, {}).get(key) if isinstance(_USER_CONFIG, dict) else None
42
+ if isinstance(user_val, int) and not isinstance(user_val, bool):
43
+ return user_val
44
+ return default
45
+
46
+
47
+ # Master switch. When false, ``observe_run`` returns a NullObserver and no
48
+ # file I/O, allocations, or prompt/tool assembly happen. Zero prod overhead.
49
+ OVK_AI_MONITORING = _resolve_bool(
50
+ "ai_monitoring", "enabled", "OVK_AI_MONITORING", True
51
+ )
52
+
53
+
54
+ def _resolve_sinks() -> list[str]:
55
+ """Resolve the sink list from env (comma-separated) or config.toml (array)."""
56
+ raw = os.environ.get("OVK_AI_MONITORING_SINKS")
57
+ if raw is not None and raw != "":
58
+ return [s.strip() for s in raw.split(",") if s.strip()]
59
+ user_val = (
60
+ _USER_CONFIG.get("ai_monitoring", {}).get("sinks")
61
+ if isinstance(_USER_CONFIG, dict)
62
+ else None
63
+ )
64
+ if isinstance(user_val, list) and user_val:
65
+ return [str(s) for s in user_val]
66
+ return ["file"]
67
+
68
+
69
+ # Comma-separated sink list (env) or TOML array (config.toml). v1 ships only
70
+ # ``file``; the parsing happens once per request in ``observer._build_sinks_from_env``
71
+ # so future sinks (s3, otlp, http, langsmith) register without touching
72
+ # existing code. Unknown names raise at first use so typos fail loud.
73
+ OVK_AI_MONITORING_SINKS = _resolve_sinks()
74
+
75
+ # Per-tool-result truncation cap (chars). Mirrors the agent's own
76
+ # ``server._truncate`` default so trace filesizes stay bounded; set 0 to
77
+ # disable truncation entirely.
78
+ OVK_AI_MONITORING_TOOL_LIMIT = _resolve_int(
79
+ "ai_monitoring", "tool_limit", "OVK_AI_MONITORING_TOOL_LIMIT", 8000
80
+ )