codex-autorunner 0.1.2__py3-none-any.whl → 1.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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +176 -47
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +155 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +22 -37
- codex_autorunner/cli.py +5 -1156
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +4 -0
- codex_autorunner/core/about_car.py +49 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_logging.py +7 -3
- codex_autorunner/core/app_server_prompts.py +27 -260
- codex_autorunner/core/app_server_threads.py +26 -28
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +12 -2
- codex_autorunner/core/config.py +587 -103
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +136 -0
- codex_autorunner/core/engine.py +1531 -866
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +202 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +88 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +131 -0
- codex_autorunner/core/flows/runtime.py +382 -0
- codex_autorunner/core/flows/store.py +568 -0
- codex_autorunner/core/flows/transition.py +138 -0
- codex_autorunner/core/flows/ux_helpers.py +257 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +136 -16
- codex_autorunner/core/locks.py +4 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/core/ports/agent_backend.py +150 -0
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/core/ports/run_event.py +91 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/review_context.py +5 -8
- codex_autorunner/core/run_index.py +6 -0
- codex_autorunner/core/runner_process.py +5 -2
- codex_autorunner/core/state.py +0 -88
- codex_autorunner/core/state_roots.py +57 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +201 -0
- codex_autorunner/core/ticket_manager_cli.py +432 -0
- codex_autorunner/core/update.py +24 -16
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +120 -11
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +98 -0
- codex_autorunner/integrations/agents/__init__.py +17 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +448 -0
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +598 -0
- codex_autorunner/integrations/agents/runner.py +91 -0
- codex_autorunner/integrations/agents/wiring.py +271 -0
- codex_autorunner/integrations/app_server/client.py +583 -152
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +204 -165
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +221 -0
- codex_autorunner/integrations/telegram/constants.py +17 -2
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
- codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +111 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +221 -42
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
- codex_autorunner/integrations/telegram/transport.py +39 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +37 -67
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +3 -0
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -624
- codex_autorunner/routes/file_chat.py +7 -0
- codex_autorunner/routes/flows.py +7 -0
- codex_autorunner/routes/messages.py +7 -0
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -188
- codex_autorunner/routes/usage.py +3 -0
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +3 -0
- codex_autorunner/server.py +3 -2
- codex_autorunner/static/agentControls.js +41 -11
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +35 -24
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +36 -8
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +344 -325
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +126 -185
- codex_autorunner/static/index.html +839 -863
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +873 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +149 -217
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/styles.css +8850 -3876
- codex_autorunner/static/tabs.js +175 -11
- codex_autorunner/static/terminal.js +32 -0
- codex_autorunner/static/terminalManager.js +34 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +844 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1988 -0
- codex_autorunner/static/utils.js +43 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +765 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +1224 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2019 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +78 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +277 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
- codex_autorunner/surfaces/web/routes/flows.py +1164 -0
- codex_autorunner/surfaces/web/routes/messages.py +459 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +280 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +417 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +27 -0
- codex_autorunner/tickets/agent_pool.py +399 -0
- codex_autorunner/tickets/files.py +89 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +97 -0
- codex_autorunner/tickets/outbox.py +244 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +881 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1771
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -587
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -396
- codex_autorunner/web/static_assets.py +4 -484
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +335 -0
- codex_autorunner-1.1.0.dist-info/METADATA +154 -0
- codex_autorunner-1.1.0.dist-info/RECORD +308 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
- codex_autorunner/agents/execution/policy.py +0 -292
- codex_autorunner/agents/factory.py +0 -52
- codex_autorunner/agents/orchestrator.py +0 -358
- codex_autorunner/core/doc_chat.py +0 -1446
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -250
- codex_autorunner/spec_ingest.py +0 -812
- codex_autorunner/static/docChatActions.js +0 -287
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -285
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -504
- codex_autorunner/static/logs.js +0 -678
- codex_autorunner/static/review.js +0 -157
- codex_autorunner/static/runs.js +0 -418
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -94
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.2.dist-info/METADATA +0 -249
- codex_autorunner-0.1.2.dist-info/RECORD +0 -222
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for route modules.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from ....core.locks import (
|
|
12
|
+
DEFAULT_RUNNER_CMD_HINTS,
|
|
13
|
+
assess_lock,
|
|
14
|
+
process_is_active,
|
|
15
|
+
read_lock_info,
|
|
16
|
+
)
|
|
17
|
+
from ....core.state import load_state
|
|
18
|
+
from ....core.utils import (
|
|
19
|
+
apply_codex_options,
|
|
20
|
+
extract_flag_value,
|
|
21
|
+
resolve_opencode_binary,
|
|
22
|
+
supports_reasoning,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
BYPASS_FLAGS = {
|
|
26
|
+
"--yolo",
|
|
27
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
SSE_HEADERS = {
|
|
31
|
+
"Cache-Control": "no-cache",
|
|
32
|
+
"X-Accel-Buffering": "no",
|
|
33
|
+
"Connection": "keep-alive",
|
|
34
|
+
"Content-Encoding": "identity",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _interruptible_sleep(
|
|
39
|
+
seconds: float, shutdown_event: Optional[asyncio.Event]
|
|
40
|
+
) -> bool:
|
|
41
|
+
"""Sleep that can be interrupted by shutdown_event. Returns True if interrupted."""
|
|
42
|
+
if shutdown_event is None:
|
|
43
|
+
await asyncio.sleep(seconds)
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
await asyncio.wait_for(shutdown_event.wait(), timeout=seconds)
|
|
47
|
+
return True # Event was set
|
|
48
|
+
except asyncio.TimeoutError:
|
|
49
|
+
return False # Normal timeout, continue
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_bypass_flag(args: list[str]) -> tuple[str, list[str]]:
|
|
53
|
+
chosen = None
|
|
54
|
+
for arg in args:
|
|
55
|
+
if arg in BYPASS_FLAGS:
|
|
56
|
+
chosen = arg
|
|
57
|
+
break
|
|
58
|
+
filtered = [arg for arg in args if arg not in BYPASS_FLAGS]
|
|
59
|
+
return chosen or "--yolo", filtered
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_codex_terminal_cmd(
|
|
63
|
+
engine,
|
|
64
|
+
*,
|
|
65
|
+
resume_mode: bool,
|
|
66
|
+
model: Optional[str] = None,
|
|
67
|
+
reasoning: Optional[str] = None,
|
|
68
|
+
) -> list[str]:
|
|
69
|
+
"""
|
|
70
|
+
Build the subprocess argv for launching the Codex interactive CLI inside a PTY.
|
|
71
|
+
"""
|
|
72
|
+
bypass_flag, terminal_args = _extract_bypass_flag(
|
|
73
|
+
list(engine.config.codex_terminal_args)
|
|
74
|
+
)
|
|
75
|
+
if resume_mode:
|
|
76
|
+
cmd = [
|
|
77
|
+
engine.config.codex_binary,
|
|
78
|
+
bypass_flag,
|
|
79
|
+
"resume",
|
|
80
|
+
*terminal_args,
|
|
81
|
+
]
|
|
82
|
+
return apply_codex_options(
|
|
83
|
+
cmd,
|
|
84
|
+
model=model,
|
|
85
|
+
reasoning=reasoning,
|
|
86
|
+
supports_reasoning=supports_reasoning(engine.config.codex_binary),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
cmd = [
|
|
90
|
+
engine.config.codex_binary,
|
|
91
|
+
bypass_flag,
|
|
92
|
+
*terminal_args,
|
|
93
|
+
]
|
|
94
|
+
return apply_codex_options(
|
|
95
|
+
cmd,
|
|
96
|
+
model=model,
|
|
97
|
+
reasoning=reasoning,
|
|
98
|
+
supports_reasoning=supports_reasoning(engine.config.codex_binary),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_opencode_terminal_cmd(binary: str, model: Optional[str] = None) -> list[str]:
|
|
103
|
+
resolved = resolve_opencode_binary(binary)
|
|
104
|
+
cmd = [resolved or binary]
|
|
105
|
+
if model:
|
|
106
|
+
cmd.extend(["--model", model])
|
|
107
|
+
return cmd
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_runner_status(engine, state) -> tuple[str, Optional[int], bool]:
|
|
111
|
+
pid = state.runner_pid
|
|
112
|
+
alive_pid = pid if pid and process_is_active(pid) else None
|
|
113
|
+
if alive_pid is None:
|
|
114
|
+
info = read_lock_info(engine.lock_path)
|
|
115
|
+
if info.pid and process_is_active(info.pid):
|
|
116
|
+
alive_pid = info.pid
|
|
117
|
+
running = alive_pid is not None
|
|
118
|
+
status = state.status
|
|
119
|
+
if status == "running" and not running:
|
|
120
|
+
status = "idle"
|
|
121
|
+
runner_pid = alive_pid if running else None
|
|
122
|
+
return status, runner_pid, running
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_lock_payload(engine) -> dict[str, object]:
|
|
126
|
+
assessment = assess_lock(
|
|
127
|
+
engine.lock_path,
|
|
128
|
+
expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS,
|
|
129
|
+
)
|
|
130
|
+
return {
|
|
131
|
+
"lock_present": engine.lock_path.exists(),
|
|
132
|
+
"lock_pid": assessment.pid,
|
|
133
|
+
"lock_freeable": assessment.freeable,
|
|
134
|
+
"lock_freeable_reason": assessment.reason,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def log_stream(
|
|
139
|
+
log_path: Path,
|
|
140
|
+
heartbeat_interval: float = 15.0,
|
|
141
|
+
shutdown_event: Optional[asyncio.Event] = None,
|
|
142
|
+
max_seconds: float = 60.0,
|
|
143
|
+
):
|
|
144
|
+
"""SSE stream generator for log file tailing."""
|
|
145
|
+
if not log_path.exists():
|
|
146
|
+
yield "data: log file not found\n\n"
|
|
147
|
+
return
|
|
148
|
+
last_emit_at = time.monotonic()
|
|
149
|
+
start_time = time.monotonic()
|
|
150
|
+
with log_path.open("r", encoding="utf-8") as f:
|
|
151
|
+
f.seek(0, 2)
|
|
152
|
+
while True:
|
|
153
|
+
if shutdown_event is not None and shutdown_event.is_set():
|
|
154
|
+
return
|
|
155
|
+
if time.monotonic() - start_time > max_seconds:
|
|
156
|
+
yield "event: timeout\ndata: Stream timeout exceeded\n\n"
|
|
157
|
+
return
|
|
158
|
+
line = f.readline()
|
|
159
|
+
if line:
|
|
160
|
+
yield f"data: {line.rstrip()}\n\n"
|
|
161
|
+
last_emit_at = time.monotonic()
|
|
162
|
+
else:
|
|
163
|
+
now = time.monotonic()
|
|
164
|
+
if now - last_emit_at >= heartbeat_interval:
|
|
165
|
+
yield ": ping\n\n"
|
|
166
|
+
last_emit_at = now
|
|
167
|
+
if await _interruptible_sleep(0.5, shutdown_event):
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def jsonl_event_stream(
|
|
172
|
+
path: Path,
|
|
173
|
+
*,
|
|
174
|
+
event_name: str = "message",
|
|
175
|
+
heartbeat_interval: float = 15.0,
|
|
176
|
+
shutdown_event: Optional[asyncio.Event] = None,
|
|
177
|
+
):
|
|
178
|
+
"""SSE stream generator for JSONL event files."""
|
|
179
|
+
last_emit_at = time.monotonic()
|
|
180
|
+
position = 0
|
|
181
|
+
while True:
|
|
182
|
+
if shutdown_event is not None and shutdown_event.is_set():
|
|
183
|
+
return
|
|
184
|
+
if not path.exists():
|
|
185
|
+
now = time.monotonic()
|
|
186
|
+
if now - last_emit_at >= heartbeat_interval:
|
|
187
|
+
yield ": ping\n\n"
|
|
188
|
+
last_emit_at = now
|
|
189
|
+
if await _interruptible_sleep(1.0, shutdown_event):
|
|
190
|
+
return
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
194
|
+
handle.seek(position)
|
|
195
|
+
while True:
|
|
196
|
+
if shutdown_event is not None and shutdown_event.is_set():
|
|
197
|
+
return
|
|
198
|
+
line = handle.readline()
|
|
199
|
+
if line:
|
|
200
|
+
position = handle.tell()
|
|
201
|
+
payload = line.strip()
|
|
202
|
+
if payload:
|
|
203
|
+
yield f"event: {event_name}\ndata: {payload}\n\n"
|
|
204
|
+
last_emit_at = time.monotonic()
|
|
205
|
+
else:
|
|
206
|
+
now = time.monotonic()
|
|
207
|
+
if now - last_emit_at >= heartbeat_interval:
|
|
208
|
+
yield ": ping\n\n"
|
|
209
|
+
last_emit_at = now
|
|
210
|
+
if await _interruptible_sleep(0.5, shutdown_event):
|
|
211
|
+
return
|
|
212
|
+
except OSError:
|
|
213
|
+
if await _interruptible_sleep(1.0, shutdown_event):
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def state_stream(
|
|
218
|
+
engine,
|
|
219
|
+
manager,
|
|
220
|
+
logger=None,
|
|
221
|
+
heartbeat_interval: float = 15.0,
|
|
222
|
+
shutdown_event: Optional[asyncio.Event] = None,
|
|
223
|
+
max_seconds: float = 60.0,
|
|
224
|
+
):
|
|
225
|
+
"""SSE stream generator for state updates."""
|
|
226
|
+
last_payload = None
|
|
227
|
+
last_error_log_at = 0.0
|
|
228
|
+
last_emit_at = time.monotonic()
|
|
229
|
+
start_time = time.monotonic()
|
|
230
|
+
terminal_idle_timeout_seconds = engine.config.terminal_idle_timeout_seconds
|
|
231
|
+
codex_model = engine.config.codex_model or extract_flag_value(
|
|
232
|
+
engine.config.codex_args, "--model"
|
|
233
|
+
)
|
|
234
|
+
while True:
|
|
235
|
+
if shutdown_event is not None and shutdown_event.is_set():
|
|
236
|
+
return
|
|
237
|
+
if time.monotonic() - start_time > max_seconds:
|
|
238
|
+
yield "event: timeout\ndata: Stream timeout exceeded\n\n"
|
|
239
|
+
return
|
|
240
|
+
emitted = False
|
|
241
|
+
try:
|
|
242
|
+
state = await asyncio.to_thread(load_state, engine.state_path)
|
|
243
|
+
outstanding, done = await asyncio.to_thread(engine.docs.todos)
|
|
244
|
+
status, runner_pid, running = resolve_runner_status(engine, state)
|
|
245
|
+
lock_payload = resolve_lock_payload(engine)
|
|
246
|
+
payload = {
|
|
247
|
+
"last_run_id": state.last_run_id,
|
|
248
|
+
"status": status,
|
|
249
|
+
"last_exit_code": state.last_exit_code,
|
|
250
|
+
"last_run_started_at": state.last_run_started_at,
|
|
251
|
+
"last_run_finished_at": state.last_run_finished_at,
|
|
252
|
+
"outstanding_count": len(outstanding),
|
|
253
|
+
"done_count": len(done),
|
|
254
|
+
"running": running,
|
|
255
|
+
"runner_pid": runner_pid,
|
|
256
|
+
**lock_payload,
|
|
257
|
+
"terminal_idle_timeout_seconds": terminal_idle_timeout_seconds,
|
|
258
|
+
"codex_model": codex_model or "auto",
|
|
259
|
+
}
|
|
260
|
+
if payload != last_payload:
|
|
261
|
+
yield f"data: {json.dumps(payload)}\n\n"
|
|
262
|
+
last_payload = payload
|
|
263
|
+
last_emit_at = time.monotonic()
|
|
264
|
+
emitted = True
|
|
265
|
+
except Exception:
|
|
266
|
+
# Don't spam logs, but don't swallow silently either.
|
|
267
|
+
now = time.time()
|
|
268
|
+
if logger is not None and (now - last_error_log_at) > 60:
|
|
269
|
+
last_error_log_at = now
|
|
270
|
+
try:
|
|
271
|
+
logger.warning("state stream error", exc_info=True)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
if not emitted:
|
|
275
|
+
now = time.monotonic()
|
|
276
|
+
if now - last_emit_at >= heartbeat_interval:
|
|
277
|
+
yield ": ping\n\n"
|
|
278
|
+
last_emit_at = now
|
|
279
|
+
if await _interruptible_sleep(1.0, shutdown_event):
|
|
280
|
+
return
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from ....core import update as update_core
|
|
10
|
+
from ....core.config import HubConfig
|
|
11
|
+
from ....core.update import (
|
|
12
|
+
UpdateInProgressError,
|
|
13
|
+
_normalize_update_ref,
|
|
14
|
+
_normalize_update_target,
|
|
15
|
+
_read_update_status,
|
|
16
|
+
_spawn_update_process,
|
|
17
|
+
_system_update_check,
|
|
18
|
+
)
|
|
19
|
+
from ....core.update_paths import resolve_update_paths
|
|
20
|
+
from ..schemas import (
|
|
21
|
+
SystemHealthResponse,
|
|
22
|
+
SystemUpdateCheckResponse,
|
|
23
|
+
SystemUpdateRequest,
|
|
24
|
+
SystemUpdateResponse,
|
|
25
|
+
SystemUpdateStatusResponse,
|
|
26
|
+
)
|
|
27
|
+
from ..static_assets import missing_static_assets
|
|
28
|
+
from ..static_refresh import refresh_static_assets
|
|
29
|
+
|
|
30
|
+
_pid_is_running = update_core._pid_is_running
|
|
31
|
+
_system_update_worker = update_core._system_update_worker
|
|
32
|
+
_update_lock_active = update_core._update_lock_active
|
|
33
|
+
_update_lock_path = update_core._update_lock_path
|
|
34
|
+
_update_status_path = update_core._update_status_path
|
|
35
|
+
shutil = update_core.shutil
|
|
36
|
+
subprocess = update_core.subprocess
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_system_routes() -> APIRouter:
|
|
40
|
+
router = APIRouter()
|
|
41
|
+
|
|
42
|
+
@router.get("/health", response_model=SystemHealthResponse)
|
|
43
|
+
async def system_health(request: Request):
|
|
44
|
+
try:
|
|
45
|
+
config = request.app.state.config
|
|
46
|
+
except AttributeError:
|
|
47
|
+
config = None
|
|
48
|
+
mode = "hub" if isinstance(config, HubConfig) else "repo"
|
|
49
|
+
base_path = getattr(request.app.state, "base_path", "")
|
|
50
|
+
asset_version = getattr(request.app.state, "asset_version", None)
|
|
51
|
+
static_dir = getattr(getattr(request.app, "state", None), "static_dir", None)
|
|
52
|
+
if not isinstance(static_dir, Path):
|
|
53
|
+
return JSONResponse(
|
|
54
|
+
{
|
|
55
|
+
"status": "error",
|
|
56
|
+
"detail": "Static UI assets missing; reinstall package",
|
|
57
|
+
"mode": mode,
|
|
58
|
+
"base_path": base_path,
|
|
59
|
+
},
|
|
60
|
+
status_code=500,
|
|
61
|
+
)
|
|
62
|
+
missing = await asyncio.to_thread(missing_static_assets, static_dir)
|
|
63
|
+
if missing:
|
|
64
|
+
if refresh_static_assets(request.app):
|
|
65
|
+
static_dir = getattr(
|
|
66
|
+
getattr(request.app, "state", None), "static_dir", None
|
|
67
|
+
)
|
|
68
|
+
if isinstance(static_dir, Path):
|
|
69
|
+
missing = await asyncio.to_thread(missing_static_assets, static_dir)
|
|
70
|
+
else:
|
|
71
|
+
missing = ["index.html"]
|
|
72
|
+
if not missing:
|
|
73
|
+
return {
|
|
74
|
+
"status": "ok",
|
|
75
|
+
"mode": mode,
|
|
76
|
+
"base_path": base_path,
|
|
77
|
+
"asset_version": asset_version,
|
|
78
|
+
}
|
|
79
|
+
return JSONResponse(
|
|
80
|
+
{
|
|
81
|
+
"status": "error",
|
|
82
|
+
"detail": "Static UI assets missing; reinstall package",
|
|
83
|
+
"missing": missing,
|
|
84
|
+
"mode": mode,
|
|
85
|
+
"base_path": base_path,
|
|
86
|
+
},
|
|
87
|
+
status_code=500,
|
|
88
|
+
)
|
|
89
|
+
return {
|
|
90
|
+
"status": "ok",
|
|
91
|
+
"mode": mode,
|
|
92
|
+
"base_path": base_path,
|
|
93
|
+
"asset_version": asset_version,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@router.get("/system/update/check", response_model=SystemUpdateCheckResponse)
|
|
97
|
+
async def system_update_check(request: Request):
|
|
98
|
+
"""
|
|
99
|
+
Check if an update is available by comparing local git state vs remote.
|
|
100
|
+
If local git state is unavailable, report that an update may be available.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
config = request.app.state.config
|
|
104
|
+
except AttributeError:
|
|
105
|
+
config = None
|
|
106
|
+
|
|
107
|
+
repo_url = "https://github.com/Git-on-my-level/codex-autorunner.git"
|
|
108
|
+
repo_ref = "main"
|
|
109
|
+
if config and isinstance(config, HubConfig):
|
|
110
|
+
configured_url = getattr(config, "update_repo_url", None)
|
|
111
|
+
if configured_url:
|
|
112
|
+
repo_url = configured_url
|
|
113
|
+
configured_ref = getattr(config, "update_repo_ref", None)
|
|
114
|
+
if configured_ref:
|
|
115
|
+
repo_ref = configured_ref
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
return await asyncio.to_thread(
|
|
119
|
+
_system_update_check, repo_url=repo_url, repo_ref=repo_ref
|
|
120
|
+
)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger = getattr(getattr(request.app, "state", None), "logger", None)
|
|
123
|
+
if logger:
|
|
124
|
+
logger.error("Update check error: %s", e, exc_info=True)
|
|
125
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
126
|
+
|
|
127
|
+
@router.post("/system/update", response_model=SystemUpdateResponse)
|
|
128
|
+
async def system_update(
|
|
129
|
+
request: Request, payload: Optional[SystemUpdateRequest] = None
|
|
130
|
+
):
|
|
131
|
+
"""
|
|
132
|
+
Pull latest code and refresh the running service.
|
|
133
|
+
This will restart the server if successful.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
config = request.app.state.config
|
|
137
|
+
except AttributeError:
|
|
138
|
+
config = None
|
|
139
|
+
|
|
140
|
+
# Determine URL
|
|
141
|
+
repo_url = "https://github.com/Git-on-my-level/codex-autorunner.git"
|
|
142
|
+
repo_ref = "main"
|
|
143
|
+
skip_checks = False
|
|
144
|
+
if config and isinstance(config, HubConfig):
|
|
145
|
+
configured_url = getattr(config, "update_repo_url", None)
|
|
146
|
+
if configured_url:
|
|
147
|
+
repo_url = configured_url
|
|
148
|
+
configured_ref = getattr(config, "update_repo_ref", None)
|
|
149
|
+
if configured_ref:
|
|
150
|
+
repo_ref = configured_ref
|
|
151
|
+
skip_checks = bool(getattr(config, "update_skip_checks", False))
|
|
152
|
+
elif config is not None:
|
|
153
|
+
skip_checks = bool(getattr(config, "update_skip_checks", False))
|
|
154
|
+
|
|
155
|
+
update_dir = resolve_update_paths(config=config).cache_dir
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
target_raw = payload.target if payload else None
|
|
159
|
+
if target_raw is None:
|
|
160
|
+
target_raw = request.query_params.get("target")
|
|
161
|
+
update_target = _normalize_update_target(target_raw)
|
|
162
|
+
logger = getattr(getattr(request.app, "state", None), "logger", None)
|
|
163
|
+
if logger is None:
|
|
164
|
+
logger = logging.getLogger("codex_autorunner.system_update")
|
|
165
|
+
await asyncio.to_thread(
|
|
166
|
+
_spawn_update_process,
|
|
167
|
+
repo_url=repo_url,
|
|
168
|
+
repo_ref=_normalize_update_ref(repo_ref),
|
|
169
|
+
update_dir=update_dir,
|
|
170
|
+
logger=logger,
|
|
171
|
+
update_target=update_target,
|
|
172
|
+
skip_checks=skip_checks,
|
|
173
|
+
)
|
|
174
|
+
return {
|
|
175
|
+
"status": "ok",
|
|
176
|
+
"message": f"Update started ({update_target}). Service will restart shortly.",
|
|
177
|
+
"target": update_target,
|
|
178
|
+
}
|
|
179
|
+
except UpdateInProgressError as exc:
|
|
180
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger = getattr(getattr(request.app, "state", None), "logger", None)
|
|
185
|
+
if logger:
|
|
186
|
+
logger.error("Update error: %s", e, exc_info=True)
|
|
187
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
188
|
+
|
|
189
|
+
@router.get("/system/update/status", response_model=SystemUpdateStatusResponse)
|
|
190
|
+
async def system_update_status():
|
|
191
|
+
status = await asyncio.to_thread(_read_update_status)
|
|
192
|
+
if status is None:
|
|
193
|
+
return {"status": "unknown", "message": "No update status recorded."}
|
|
194
|
+
return status
|
|
195
|
+
|
|
196
|
+
return router
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Usage routes: token usage summaries for repo/hub.
|
|
3
|
+
|
|
4
|
+
Moved out of the legacy docs routes during the workspace + file chat cutover.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
12
|
+
|
|
13
|
+
from ....core.usage import (
|
|
14
|
+
UsageError,
|
|
15
|
+
default_codex_home,
|
|
16
|
+
get_repo_usage_series_cached,
|
|
17
|
+
get_repo_usage_summary_cached,
|
|
18
|
+
parse_iso_datetime,
|
|
19
|
+
)
|
|
20
|
+
from ..schemas import RepoUsageResponse, UsageSeriesResponse
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_usage_routes() -> APIRouter:
|
|
24
|
+
router = APIRouter(prefix="/api", tags=["usage"])
|
|
25
|
+
|
|
26
|
+
@router.get("/usage", response_model=RepoUsageResponse)
|
|
27
|
+
def get_usage(
|
|
28
|
+
request: Request, since: Optional[str] = None, until: Optional[str] = None
|
|
29
|
+
):
|
|
30
|
+
engine = request.app.state.engine
|
|
31
|
+
try:
|
|
32
|
+
since_dt = parse_iso_datetime(since)
|
|
33
|
+
until_dt = parse_iso_datetime(until)
|
|
34
|
+
except UsageError as exc:
|
|
35
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
36
|
+
summary, status = get_repo_usage_summary_cached(
|
|
37
|
+
engine.repo_root,
|
|
38
|
+
default_codex_home(),
|
|
39
|
+
config=engine.config,
|
|
40
|
+
since=since_dt,
|
|
41
|
+
until=until_dt,
|
|
42
|
+
)
|
|
43
|
+
return {
|
|
44
|
+
"mode": "repo",
|
|
45
|
+
"repo": str(engine.repo_root),
|
|
46
|
+
"codex_home": str(default_codex_home()),
|
|
47
|
+
"since": since,
|
|
48
|
+
"until": until,
|
|
49
|
+
"status": status,
|
|
50
|
+
**summary.to_dict(),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@router.get("/usage/series", response_model=UsageSeriesResponse)
|
|
54
|
+
def get_usage_series(
|
|
55
|
+
request: Request,
|
|
56
|
+
since: Optional[str] = None,
|
|
57
|
+
until: Optional[str] = None,
|
|
58
|
+
bucket: str = "day",
|
|
59
|
+
segment: str = "none",
|
|
60
|
+
):
|
|
61
|
+
engine = request.app.state.engine
|
|
62
|
+
try:
|
|
63
|
+
since_dt = parse_iso_datetime(since)
|
|
64
|
+
until_dt = parse_iso_datetime(until)
|
|
65
|
+
except UsageError as exc:
|
|
66
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
67
|
+
try:
|
|
68
|
+
series, status = get_repo_usage_series_cached(
|
|
69
|
+
engine.repo_root,
|
|
70
|
+
default_codex_home(),
|
|
71
|
+
config=engine.config,
|
|
72
|
+
since=since_dt,
|
|
73
|
+
until=until_dt,
|
|
74
|
+
bucket=bucket,
|
|
75
|
+
segment=segment,
|
|
76
|
+
)
|
|
77
|
+
except UsageError as exc:
|
|
78
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
79
|
+
return {
|
|
80
|
+
"mode": "repo",
|
|
81
|
+
"repo": str(engine.repo_root),
|
|
82
|
+
"codex_home": str(default_codex_home()),
|
|
83
|
+
"since": since,
|
|
84
|
+
"until": until,
|
|
85
|
+
"status": status,
|
|
86
|
+
**series,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return router
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Voice transcription and configuration routes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
|
10
|
+
|
|
11
|
+
from ....voice import VoiceService, VoiceServiceError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("codex_autorunner.routes.voice")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_voice_routes() -> APIRouter:
|
|
17
|
+
"""Build routes for voice transcription and config."""
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
@router.get("/api/voice/config")
|
|
21
|
+
def get_voice_config(request: Request):
|
|
22
|
+
voice_service: Optional[VoiceService] = request.app.state.voice_service
|
|
23
|
+
voice_config = request.app.state.voice_config
|
|
24
|
+
missing_reason = getattr(request.app.state, "voice_missing_reason", None)
|
|
25
|
+
if missing_reason:
|
|
26
|
+
return {
|
|
27
|
+
"enabled": False,
|
|
28
|
+
"provider": voice_config.provider,
|
|
29
|
+
"latency_mode": voice_config.latency_mode,
|
|
30
|
+
"chunk_ms": voice_config.chunk_ms,
|
|
31
|
+
"sample_rate": voice_config.sample_rate,
|
|
32
|
+
"warn_on_remote_api": voice_config.warn_on_remote_api,
|
|
33
|
+
"has_api_key": False,
|
|
34
|
+
"push_to_talk": {
|
|
35
|
+
"max_ms": voice_config.push_to_talk.max_ms,
|
|
36
|
+
"silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
|
|
37
|
+
"min_hold_ms": voice_config.push_to_talk.min_hold_ms,
|
|
38
|
+
},
|
|
39
|
+
"missing_extra": missing_reason,
|
|
40
|
+
}
|
|
41
|
+
if voice_service is None:
|
|
42
|
+
# Degrade gracefully: still return config to the UI even if service init failed.
|
|
43
|
+
try:
|
|
44
|
+
return VoiceService(
|
|
45
|
+
voice_config, logger=request.app.state.logger
|
|
46
|
+
).config_payload()
|
|
47
|
+
except (ValueError, TypeError, OSError) as exc:
|
|
48
|
+
logger.debug("Failed to create VoiceService for config: %s", exc)
|
|
49
|
+
return {
|
|
50
|
+
"enabled": False,
|
|
51
|
+
"provider": voice_config.provider,
|
|
52
|
+
"latency_mode": voice_config.latency_mode,
|
|
53
|
+
"chunk_ms": voice_config.chunk_ms,
|
|
54
|
+
"sample_rate": voice_config.sample_rate,
|
|
55
|
+
"warn_on_remote_api": voice_config.warn_on_remote_api,
|
|
56
|
+
"has_api_key": False,
|
|
57
|
+
"push_to_talk": {
|
|
58
|
+
"max_ms": voice_config.push_to_talk.max_ms,
|
|
59
|
+
"silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
|
|
60
|
+
"min_hold_ms": voice_config.push_to_talk.min_hold_ms,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
return voice_service.config_payload()
|
|
64
|
+
|
|
65
|
+
@router.post("/api/voice/transcribe")
|
|
66
|
+
async def transcribe_voice(
|
|
67
|
+
request: Request,
|
|
68
|
+
file: Optional[UploadFile] = File(None),
|
|
69
|
+
language: Optional[str] = None,
|
|
70
|
+
):
|
|
71
|
+
voice_service: Optional[VoiceService] = request.app.state.voice_service
|
|
72
|
+
voice_config = request.app.state.voice_config
|
|
73
|
+
missing_reason = getattr(request.app.state, "voice_missing_reason", None)
|
|
74
|
+
if missing_reason:
|
|
75
|
+
raise HTTPException(status_code=503, detail=missing_reason)
|
|
76
|
+
if not voice_service or not voice_config.enabled:
|
|
77
|
+
raise HTTPException(status_code=400, detail="Voice is disabled")
|
|
78
|
+
|
|
79
|
+
filename: Optional[str] = None
|
|
80
|
+
content_type: Optional[str] = None
|
|
81
|
+
if file is not None:
|
|
82
|
+
filename = file.filename
|
|
83
|
+
content_type = file.content_type
|
|
84
|
+
try:
|
|
85
|
+
audio_bytes = await file.read()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=400, detail="Unable to read audio upload"
|
|
89
|
+
) from exc
|
|
90
|
+
else:
|
|
91
|
+
audio_bytes = await request.body()
|
|
92
|
+
try:
|
|
93
|
+
result = await asyncio.to_thread(
|
|
94
|
+
voice_service.transcribe,
|
|
95
|
+
audio_bytes,
|
|
96
|
+
client="web",
|
|
97
|
+
user_agent=request.headers.get("user-agent"),
|
|
98
|
+
language=language,
|
|
99
|
+
filename=filename,
|
|
100
|
+
content_type=content_type,
|
|
101
|
+
)
|
|
102
|
+
except VoiceServiceError as exc:
|
|
103
|
+
if exc.reason == "unauthorized":
|
|
104
|
+
status = 401
|
|
105
|
+
elif exc.reason == "forbidden":
|
|
106
|
+
status = 403
|
|
107
|
+
elif exc.reason == "audio_too_large":
|
|
108
|
+
status = 413
|
|
109
|
+
elif exc.reason == "rate_limited":
|
|
110
|
+
status = 429
|
|
111
|
+
else:
|
|
112
|
+
status = (
|
|
113
|
+
400
|
|
114
|
+
if exc.reason in ("disabled", "empty_audio", "invalid_audio")
|
|
115
|
+
else 502
|
|
116
|
+
)
|
|
117
|
+
raise HTTPException(status_code=status, detail=exc.detail) from exc
|
|
118
|
+
return {"status": "ok", **result}
|
|
119
|
+
|
|
120
|
+
return router
|