Leon4gr45 commited on
Commit
5cbbbd0
·
verified ·
1 Parent(s): 0edb5b9

Delete vrag/mentor_api.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. vrag/mentor_api.py +0 -371
vrag/mentor_api.py DELETED
@@ -1,371 +0,0 @@
1
- """Versioned configuration and live contracts; raw media is processed in memory only."""
2
- import asyncio
3
- import base64
4
- from collections import deque
5
- from datetime import datetime, timezone
6
- import hashlib
7
- import hmac
8
- import io
9
- import os
10
- import secrets
11
- import time
12
- import uuid
13
-
14
- from fastapi import APIRouter, Depends, Header, HTTPException, Request
15
- from fastapi.responses import Response
16
- from PIL import Image, UnidentifiedImageError
17
- from pydantic import Field, ValidationError
18
- import yaml
19
-
20
- from config.schema import Strict, parse_yaml
21
- from .providers import Decision, Detection, ProviderFailure, destination
22
- from .routing import route
23
- from .settings import store
24
-
25
- router = APIRouter()
26
-
27
- def auth(request: Request, admin=False):
28
- expected = os.environ.get('VM_ADMIN_TOKEN' if admin else 'VM_USER_TOKEN', '')
29
- value = request.headers.get('authorization', '')
30
- if not expected or len(expected) < 24 or not hmac.compare_digest(value.encode('utf-8'), ('Bearer ' + expected).encode('utf-8')):
31
- raise HTTPException(401, 'configure a server token (at least 24 characters) and authenticate')
32
- # Bearer authorization avoids ambient-cookie CSRF. Also reject cross-origin browser requests.
33
- origin = request.headers.get('origin')
34
- if origin:
35
- allowed = set(filter(None, os.environ.get('VM_ALLOWED_ORIGINS', '').split(',')))
36
- forwarded = request.headers.get('x-forwarded-host') if request.client and request.client.host in ('127.0.0.1', '::1', 'testclient') else None
37
- host = forwarded or request.headers.get('host', '')
38
- if (allowed and origin not in allowed) or (not allowed and origin not in ('https://' + host, 'http://' + host)):
39
- raise HTTPException(403, 'origin denied')
40
- return hashlib.sha256(value.encode()).hexdigest()
41
-
42
- def admin(request: Request):
43
- return auth(request, True)
44
-
45
- def user(request: Request):
46
- return auth(request)
47
-
48
- class ConfigInput(Strict):
49
- yaml: str = Field(max_length=65536)
50
- expected_version: int = Field(default=1, ge=1)
51
-
52
- class Rollback(Strict):
53
- version: int = Field(ge=1)
54
- expected_version: int = Field(ge=1)
55
-
56
- class DryRunInput(Strict):
57
- feature: str = Field(min_length=1, max_length=100)
58
- camera_id: str = Field(default="camera1", pattern="^camera[12]$")
59
-
60
- class SessionInput(Strict):
61
- consent: bool
62
-
63
- class FrameInput(Strict):
64
- camera_id: str = Field(pattern='^camera[12]$')
65
- frame_id: int = Field(ge=0, le=9007199254740991)
66
- captured_at: float = Field(ge=0, allow_inf_nan=False)
67
- image: str = Field(max_length=1500000)
68
- feature: str = Field(min_length=1, max_length=100)
69
- config_version: int = Field(ge=1)
70
- request_id: str = Field(max_length=100)
71
-
72
- class ProviderStatus(Strict):
73
- provider: str
74
- status: str
75
- latency_ms: int
76
-
77
- class Observation(Strict):
78
- session_id: str
79
- camera_id: str
80
- frame_id: int
81
- captured_at: float
82
- request_id: str
83
- config_version: int
84
- observed_at: datetime
85
- source_width: int
86
- source_height: int
87
- detections: list[Detection]
88
- decisions: list[Decision]
89
- provider_results: list[ProviderStatus]
90
- warnings: list[str]
91
-
92
- class AudioInput(Strict):
93
- audio: str = Field(max_length=2800000)
94
- duration_ms: int = Field(ge=1, le=15000)
95
-
96
- class Session:
97
- def __init__(self, owner):
98
- self.owner = owner
99
- self.expiry = time.monotonic() + 1800
100
- self.last = {}
101
- self.busy = set()
102
- self.rate = {}
103
- self.events = deque(maxlen=20)
104
- self.task = {'goal':'', 'step':'Observe', 'completed':False}
105
- self.last_transcript = ''
106
-
107
- sessions = {}
108
-
109
- def reap_sessions():
110
- now = time.monotonic()
111
- for sid in list(sessions):
112
- if sessions[sid].expiry <= now:
113
- del sessions[sid]
114
-
115
- async def session_cleanup():
116
- while True:
117
- reap_sessions()
118
- await asyncio.sleep(30)
119
-
120
- def session_for(sid, owner):
121
- reap_sessions()
122
- s = sessions.get(sid)
123
- if not s or s.owner != owner:
124
- raise HTTPException(404, 'session expired or unavailable')
125
- return s
126
-
127
- @router.get('/api/health')
128
- def health():
129
- return {'status': 'ok', 'api_version': 1, 'inference': 'remote-only', 'config_version': store.version,
130
- 'configuration': 'state-directory-configured' if store.path else 'read-only-defaults',
131
- 'providers_configured': len(store.history[-1].providers), 'sessions': 'ephemeral'}
132
-
133
- def provider_host(p):
134
- from urllib.parse import urlsplit
135
- try:
136
- return urlsplit(os.environ.get(p.base_url_env, '')).hostname
137
- except ValueError:
138
- return None
139
-
140
- @router.get('/api/providers')
141
- def providers(owner=Depends(user)):
142
- version, cfg = store.snapshot()
143
- return {'config_version': version, 'providers': {k: {'label': p.label, 'adapter': p.adapter, 'model': p.model,
144
- 'capabilities': p.capabilities, 'trust': p.trust, 'credential_present': bool(os.environ.get(p.api_key_secret, '')) if p.api_key_secret else True,
145
- 'destination': provider_host(p),
146
- 'protocol_verified': True, 'live_status': 'not_checked'} for k,p in cfg.providers.items()},
147
- 'features': {k: v.model_dump() for k,v in cfg.features.items()}}
148
-
149
- @router.get('/api/config')
150
- def get_config(owner=Depends(admin)):
151
- version, cfg = store.snapshot()
152
- return {'config_version': version, 'yaml': yaml.safe_dump(cfg.model_dump(), sort_keys=False),
153
- 'history': list(range(1, version+1)), 'writable': bool(store.path), 'config':cfg.model_dump()}
154
-
155
- @router.get('/api/config/template')
156
- def template(owner=Depends(admin)):
157
- from pathlib import Path
158
- return Response((Path(__file__).parent.parent/'config/examples/disabled.yaml').read_text(), media_type='application/yaml', headers={'Content-Disposition': 'attachment; filename=visual-mentor.yaml'})
159
-
160
- async def validate(text):
161
- try:
162
- cfg = parse_yaml(text)
163
- except ValidationError as exc:
164
- known = {'version','profile','providers','features','adapter','base_url_env','api_key_secret','model','capabilities','trust','timeout_ms','concurrency','strategy','result_policy','quorum','fusion','enabled','conditions','cameras','privacy','record_video','analysis_fps','width','height'}
165
- errors = ['.'.join(str(k) if k in known else '<entry>' for k in e['loc']) + ': ' + e['type'] for e in exc.errors(include_input=False, include_context=False)][:8]
166
- raise HTTPException(422, 'Configuration schema errors: ' + '; '.join(errors)) from None
167
- except Exception:
168
- # Never echo YAML or validation input (it may contain pasted secrets).
169
- raise HTTPException(422, 'Invalid YAML/schema: use unique keys, no aliases, known providers and compatible capabilities; check template') from None
170
- active = {pid for rule in cfg.features.values() if rule.enabled for pid in rule.providers}
171
- for pid in active:
172
- try:
173
- await destination(cfg.providers[pid], False)
174
- except ProviderFailure:
175
- raise HTTPException(422, 'Provider endpoint missing or denied: configure HTTPS public host in VM_ALLOWED_HOSTS and endpoint environment variable') from None
176
- return cfg
177
-
178
- @router.get('/api/config/profiles')
179
- def profiles(owner=Depends(admin)):
180
- from pathlib import Path
181
- directory = Path(__file__).parent.parent/'config/examples'
182
- return {p.stem:p.read_text() for p in directory.glob('*.yaml')}
183
-
184
- @router.post('/api/config/validate')
185
- async def validate_config(body: ConfigInput, owner=Depends(admin)):
186
- cfg = await validate(body.yaml)
187
- old = store.history[-1].model_dump()
188
- new = cfg.model_dump()
189
- return {'valid': True, 'config_version': store.version, 'changed_sections': [k for k in new if new[k] != old[k]], 'profile': cfg.profile}
190
-
191
- @router.post('/api/config/apply')
192
- async def apply_config(body: ConfigInput, owner=Depends(admin)):
193
- cfg = await validate(body.yaml)
194
- try:
195
- version = await store.apply(cfg, body.expected_version)
196
- except ValueError as exc:
197
- raise HTTPException(409, str(exc)) from None
198
- return {'config_version': version, 'profile': cfg.profile}
199
-
200
- @router.post('/api/config/rollback')
201
- async def rollback(body: Rollback, owner=Depends(admin)):
202
- if body.version > len(store.history):
203
- raise HTTPException(404, 'unknown version')
204
- cfg = store.history[body.version-1]
205
- # Endpoints may have changed since this version was saved; validate again.
206
- await validate(yaml.safe_dump(cfg.model_dump()))
207
- try:
208
- version = await store.apply(cfg, body.expected_version)
209
- except ValueError as exc:
210
- raise HTTPException(409, str(exc)) from None
211
- return {'config_version': version, 'restored_version': body.version}
212
-
213
- @router.post('/api/providers/{pid}/test')
214
- async def test_provider(pid: str, owner=Depends(admin)):
215
- from .providers import adapter
216
- _, cfg = store.snapshot()
217
- if pid not in cfg.providers:
218
- raise HTTPException(404, 'unknown provider')
219
- p = cfg.providers[pid]
220
- # Only a synthetic image/text; microphone tests require an explicit bounded clip in a live session.
221
- if p.adapter == 'openai-transcription':
222
- raise HTTPException(409, 'Use an explicitly enabled live audio clip to test transcription')
223
- image = Image.new('RGB', (32,32), 'white')
224
- out = io.BytesIO(); image.save(out, format='JPEG')
225
- payload = {'text': 'Synthetic connection test. Respond with OK.', 'image': 'data:image/jpeg;base64,' + base64.b64encode(out.getvalue()).decode(), 'camera_id': 'test', 'frame_id': 0, 'width':32, 'height':32}
226
- if p.adapter == 'openai-chat-vision' and 'image_understanding' not in p.capabilities:
227
- payload.pop('image')
228
- start = time.monotonic()
229
- await adapter.call(pid, p, payload, False)
230
- return {'provider': pid, 'status':'ok', 'latency_ms': round((time.monotonic()-start)*1000), 'synthetic': True}
231
-
232
- @router.post('/api/routing/dry-run')
233
- async def dry_run(body: DryRunInput, owner=Depends(admin)):
234
- _, cfg = store.snapshot()
235
- f = io.BytesIO(); Image.new('RGB', (32,32), 'white').save(f, format='JPEG')
236
- payload = {'text':'Synthetic routing test; no real camera data.',
237
- 'camera_id':body.camera_id, 'frame_id':0, 'width':32, 'height':32}
238
- if body.feature.startswith('live.'):
239
- payload['image']='data:image/jpeg;base64,' + base64.b64encode(f.getvalue()).decode()
240
- out, statuses = await route(cfg, body.feature, payload, False)
241
- return {'synthetic':True, 'provider_results':statuses, 'detections':[d.model_dump() for d in out.detections], 'decisions':[d.model_dump() for d in out.decisions]}
242
-
243
- async def disconnectable(request, operation):
244
- task = asyncio.create_task(operation)
245
- try:
246
- while not task.done():
247
- done, _ = await asyncio.wait({task}, timeout=0.25)
248
- if done:
249
- break
250
- if await request.is_disconnected():
251
- task.cancel()
252
- raise HTTPException(499, 'client disconnected')
253
- return await task
254
- finally:
255
- if not task.done():
256
- task.cancel()
257
- await asyncio.gather(task, return_exceptions=True)
258
-
259
- @router.post('/api/live/sessions')
260
- async def create_session(body: SessionInput, owner=Depends(user)):
261
- if not body.consent:
262
- raise HTTPException(400, 'explicit transfer consent required')
263
- reap_sessions()
264
- if len(sessions) >= 32 or sum(s.owner == owner for s in sessions.values()) >= 4:
265
- raise HTTPException(429, 'session limit')
266
- sid = secrets.token_urlsafe(24)
267
- sessions[sid] = Session(owner)
268
- return {'session_id': sid, 'config_version': store.version, 'expires_in': 1800, 'retention': 'in-memory observations only'}
269
-
270
- @router.delete('/api/live/sessions/{sid}')
271
- async def delete_session(sid: str, owner=Depends(user)):
272
- session_for(sid, owner)
273
- del sessions[sid]
274
- return {'deleted': True}
275
-
276
- @router.post('/api/live/sessions/{sid}/frames', response_model=Observation)
277
- async def analyze_frame(sid: str, body: FrameInput, request: Request, owner=Depends(user)):
278
- s = session_for(sid, owner)
279
- version, cfg = store.snapshot()
280
- if body.config_version != version:
281
- raise HTTPException(409, 'configuration changed; refresh session routing')
282
- if not body.feature.startswith(f'live.{body.camera_id}.') and body.feature != 'live.visual_decision':
283
- raise HTTPException(422, 'feature must belong to this logical camera')
284
- if body.camera_id in s.busy:
285
- raise HTTPException(429, 'one analysis per camera is already in flight')
286
- if body.frame_id <= s.last.get(body.camera_id, -1):
287
- raise HTTPException(409, 'stale frame')
288
- if time.monotonic() - s.rate.get(body.camera_id, 0) < 0.5:
289
- raise HTTPException(429, 'maximum 2 FPS')
290
- try:
291
- prefix, encoded = body.image.split(',', 1)
292
- if prefix not in ('data:image/jpeg;base64', 'data:image/webp;base64'):
293
- raise ValueError()
294
- raw = base64.b64decode(encoded, validate=True)
295
- with Image.open(io.BytesIO(raw)) as image:
296
- if image.format not in ('JPEG','WEBP') or not (1 <= image.width <= 1920 and 1 <= image.height <= 1080):
297
- raise ValueError()
298
- width, height = image.size
299
- image.verify()
300
- except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError):
301
- raise HTTPException(422, 'invalid or oversized JPEG/WebP image') from None
302
- s.busy.add(body.camera_id)
303
- s.last[body.camera_id] = body.frame_id
304
- s.rate[body.camera_id] = time.monotonic()
305
- received = datetime.now(timezone.utc).isoformat()
306
- try:
307
- out, statuses = await disconnectable(request, route(cfg, body.feature, {**body.model_dump(), 'width':width, 'height':height}))
308
- finally:
309
- s.busy.discard(body.camera_id)
310
- # Deleting a session while an inference request is in flight discards its result.
311
- session_for(sid, owner)
312
- result = {'session_id':sid, 'camera_id':body.camera_id, 'frame_id':body.frame_id, 'captured_at':body.captured_at,
313
- 'request_id':body.request_id, 'config_version':version, 'observed_at':received,
314
- 'source_width':width, 'source_height':height, 'detections':[d.model_dump() for d in out.detections],
315
- 'decisions':[d.model_dump() for d in out.decisions], 'provider_results':statuses,
316
- 'warnings':out.warnings + (['No successful provider result'] if not any(r['status']=='ok' for r in statuses) else [])}
317
- s.events.append(result)
318
- return result
319
-
320
- @router.get('/api/live/sessions/{sid}/events')
321
- async def events(sid: str, owner=Depends(user)):
322
- return list(session_for(sid, owner).events)
323
-
324
- @router.post('/api/live/sessions/{sid}/transcribe')
325
- async def transcribe(sid: str, body: AudioInput, request: Request, owner=Depends(user)):
326
- s = session_for(sid, owner)
327
- if 'audio' in s.busy:
328
- raise HTTPException(429, 'audio request in flight')
329
- if time.monotonic() - s.rate.get('audio', 0) < 2:
330
- raise HTTPException(429, 'audio rate limit')
331
- try:
332
- audio = base64.b64decode(body.audio, validate=True)
333
- if not audio or len(audio) > 2000000 or audio[:4] != b'\x1aE\xdf\xa3':
334
- raise ValueError()
335
- except ValueError:
336
- raise HTTPException(422, 'bounded WebM audio required') from None
337
- _, cfg = store.snapshot()
338
- s.busy.add('audio'); s.rate['audio'] = time.monotonic()
339
- try:
340
- out, statuses = await disconnectable(request, route(cfg, 'audio.transcription', {'audio':audio}))
341
- finally:
342
- s.busy.discard('audio')
343
- session_for(sid, owner)
344
- s.last_transcript = out.text[:8000]
345
- return {'text':out.text, 'provider_results':statuses, 'duration_ms':body.duration_ms}
346
-
347
- @router.post('/api/live/sessions/{sid}/guidance')
348
- async def guidance(sid: str, request: Request, owner=Depends(user)):
349
- s = session_for(sid, owner)
350
- if 'mentor' in s.busy or time.monotonic() - s.rate.get('mentor', 0) < 2:
351
- raise HTTPException(429, 'guidance rate limit')
352
- _, cfg = store.snapshot()
353
- s.busy.add('mentor'); s.rate['mentor'] = time.monotonic()
354
- try:
355
- out, statuses = await disconnectable(request, route(cfg, 'mentor.reasoning', {'text': 'Give cautious next-step guidance from these observations, identify uncertainty. No autonomous actions. ' + str(s.task) + ' Recent speech (untrusted): ' + s.last_transcript + str(list(s.events)[-5:])[:24000]}))
356
- finally:
357
- s.busy.discard('mentor')
358
- session_for(sid, owner)
359
- return {'guidance':out.text, 'provider_results':statuses, 'autonomous':False}
360
-
361
-
362
- class TaskInput(Strict):
363
- goal: str = Field(max_length=1000)
364
- step: str = Field(max_length=500)
365
- completed: bool = False
366
-
367
- @router.put('/api/live/sessions/{sid}/task')
368
- async def task_state(sid: str, body: TaskInput, owner=Depends(user)):
369
- s = session_for(sid, owner)
370
- s.task = body.model_dump()
371
- return {'task':s.task, 'autonomous':False}