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.
- openvideokit/__init__.py +7 -0
- openvideokit/__main__.py +6 -0
- openvideokit/ai/__init__.py +9 -0
- openvideokit/ai/config.py +121 -0
- openvideokit/ai/context.py +64 -0
- openvideokit/ai/events.py +78 -0
- openvideokit/ai/graph.py +35 -0
- openvideokit/ai/llm.py +87 -0
- openvideokit/ai/monitor/__init__.py +18 -0
- openvideokit/ai/monitor/config.py +80 -0
- openvideokit/ai/monitor/observer.py +462 -0
- openvideokit/ai/monitor/sinks.py +605 -0
- openvideokit/ai/monitor/types.py +142 -0
- openvideokit/ai/ops.py +205 -0
- openvideokit/ai/prompts/__init__.py +97 -0
- openvideokit/ai/prompts/caption_rules.py +21 -0
- openvideokit/ai/prompts/html_contract.py +30 -0
- openvideokit/ai/prompts/model.py +42 -0
- openvideokit/ai/prompts/project_context.py +58 -0
- openvideokit/ai/prompts/role.py +107 -0
- openvideokit/ai/prompts/safety.py +20 -0
- openvideokit/ai/prompts/templates.py +68 -0
- openvideokit/ai/prompts/tools.py +75 -0
- openvideokit/ai/prompts/voice_rules.py +44 -0
- openvideokit/ai/providers.py +103 -0
- openvideokit/ai/sandbox.py +272 -0
- openvideokit/ai/server.py +341 -0
- openvideokit/ai/tools/__init__.py +88 -0
- openvideokit/ai/tools/_lint.py +159 -0
- openvideokit/ai/tools/_registry.py +95 -0
- openvideokit/ai/tools/_template.py +108 -0
- openvideokit/ai/tools/_voicelist.py +33 -0
- openvideokit/ai/tools/_web_async.py +53 -0
- openvideokit/ai/tools/add_many_slides.py +145 -0
- openvideokit/ai/tools/add_slide.py +151 -0
- openvideokit/ai/tools/apply_template.py +83 -0
- openvideokit/ai/tools/copy_slide_html.py +96 -0
- openvideokit/ai/tools/duplicate_slide.py +32 -0
- openvideokit/ai/tools/grep_slides.py +48 -0
- openvideokit/ai/tools/list_files.py +39 -0
- openvideokit/ai/tools/list_slides.py +44 -0
- openvideokit/ai/tools/list_templates.py +57 -0
- openvideokit/ai/tools/list_voices.py +84 -0
- openvideokit/ai/tools/read_file.py +61 -0
- openvideokit/ai/tools/read_many_files.py +58 -0
- openvideokit/ai/tools/read_templates.py +115 -0
- openvideokit/ai/tools/remove_many_slides.py +72 -0
- openvideokit/ai/tools/reorder_slides.py +46 -0
- openvideokit/ai/tools/run_python_sandbox.py +48 -0
- openvideokit/ai/tools/set_caption_settings.py +63 -0
- openvideokit/ai/tools/set_caption_style.py +35 -0
- openvideokit/ai/tools/set_field.py +42 -0
- openvideokit/ai/tools/set_many_durations.py +74 -0
- openvideokit/ai/tools/set_many_fields.py +77 -0
- openvideokit/ai/tools/set_many_slides_html.py +80 -0
- openvideokit/ai/tools/set_many_voiceovers.py +92 -0
- openvideokit/ai/tools/set_voiceover.py +107 -0
- openvideokit/ai/tools/tree.py +155 -0
- openvideokit/ai/tools/web_fetch.py +126 -0
- openvideokit/ai/tools/web_search.py +68 -0
- openvideokit/app.py +207 -0
- openvideokit/assets.py +254 -0
- openvideokit/captions.py +425 -0
- openvideokit/chats.py +160 -0
- openvideokit/cli.py +784 -0
- openvideokit/composition.py +177 -0
- openvideokit/config.py +153 -0
- openvideokit/events.py +61 -0
- openvideokit/gsap_validator.py +290 -0
- openvideokit/mcp/__init__.py +26 -0
- openvideokit/mcp/apply_op.py +318 -0
- openvideokit/mcp/persist.py +247 -0
- openvideokit/mcp/server.py +262 -0
- openvideokit/rendering.py +649 -0
- openvideokit/routes.py +745 -0
- openvideokit/seed.py +182 -0
- openvideokit/stamp.py +45 -0
- openvideokit/store.py +579 -0
- openvideokit/templates.py +216 -0
- openvideokit/templates_preview.py +272 -0
- openvideokit/tts_queue.py +193 -0
- openvideokit/user_config.py +363 -0
- openvideokit/validation.py +104 -0
- openvideokit/voiceover.py +362 -0
- openvideokit/voices.py +180 -0
- openvideokit/watcher.py +97 -0
- openvideokit-0.1.0.dist-info/METADATA +26 -0
- openvideokit-0.1.0.dist-info/RECORD +90 -0
- openvideokit-0.1.0.dist-info/WHEEL +4 -0
- openvideokit-0.1.0.dist-info/entry_points.txt +3 -0
openvideokit/__init__.py
ADDED
openvideokit/__main__.py
ADDED
|
@@ -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)
|
openvideokit/ai/graph.py
ADDED
|
@@ -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
|
+
)
|