sales-model 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.
- app/__init__.py +0 -0
- app/auth/__init__.py +0 -0
- app/auth/api_keys.py +290 -0
- app/auth/jwt.py +103 -0
- app/auth/rate_limit.py +41 -0
- app/auth/rate_limiter.py +354 -0
- app/auth/security.py +367 -0
- app/billing/__init__.py +24 -0
- app/billing/usage.py +488 -0
- app/dashboard/__init__.py +1 -0
- app/dashboard/data.py +139 -0
- app/dashboard/data_backup.py +942 -0
- app/dashboard/models.py +387 -0
- app/dashboard/postgres_data.py +1208 -0
- app/dashboard/routes.py +1006 -0
- app/main.py +587 -0
- app/main_v2.py +693 -0
- app/observability/__init__.py +0 -0
- app/observability/logging.py +23 -0
- app/observability/metrics.py +9 -0
- app/observability/tracing.py +5 -0
- app/providers/__init__.py +0 -0
- app/providers/azure_foundry_stt.py +111 -0
- app/providers/azure_foundry_tts.py +123 -0
- app/providers/llm_base.py +15 -0
- app/providers/null_stt.py +28 -0
- app/providers/null_tts.py +13 -0
- app/providers/stt_base.py +27 -0
- app/providers/tts_base.py +8 -0
- app/sales_brain/__init__.py +0 -0
- app/sales_brain/brain.py +26 -0
- app/sales_brain/chunker.py +48 -0
- app/storage/__init__.py +0 -0
- app/storage/database.py +761 -0
- app/storage/postgres.py +17 -0
- app/storage/redis.py +176 -0
- app/storage/schema.sql +319 -0
- app/utils/__init__.py +1 -0
- app/utils/latency.py +323 -0
- app/voice/__init__.py +0 -0
- app/voice/audio.py +8 -0
- app/voice/session.py +225 -0
- app/voice/ssml.py +32 -0
- app/voice/vad.py +6 -0
- app/voice/voicelive.py +324 -0
- app/voice/ws.py +144 -0
- app/webui/app.js +384 -0
- app/webui/index.html +90 -0
- app/webui/styles.css +267 -0
- sales_model/__init__.py +8 -0
- sales_model/ai.py +54 -0
- sales_model/cli.py +51 -0
- sales_model/config.py +37 -0
- sales_model/context_utils.py +170 -0
- sales_model/crm.py +20 -0
- sales_model/inventory.py +144 -0
- sales_model/playbook.py +37 -0
- sales_model/prompt_cache.py +14 -0
- sales_model/prompt_compiler.py +47 -0
- sales_model/prompt_registry.py +102 -0
- sales_model/sales_brain.py +731 -0
- sales_model/schemas.py +57 -0
- sales_model/status_engine.py +258 -0
- sales_model/tactics.py +210 -0
- sales_model-0.1.0.dist-info/METADATA +107 -0
- sales_model-0.1.0.dist-info/RECORD +68 -0
- sales_model-0.1.0.dist-info/WHEEL +4 -0
- sales_model-0.1.0.dist-info/entry_points.txt +2 -0
app/__init__.py
ADDED
|
File without changes
|
app/auth/__init__.py
ADDED
|
File without changes
|
app/auth/api_keys.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API Key Management System for Organization Access
|
|
3
|
+
|
|
4
|
+
Provides secure API key generation, validation, and organization-level access control.
|
|
5
|
+
Supports tiered pricing plans with different rate limits and features.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from app.observability.logging import get_logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Plan(str, Enum):
|
|
22
|
+
FREE = "free"
|
|
23
|
+
STARTER = "starter"
|
|
24
|
+
PROFESSIONAL = "professional"
|
|
25
|
+
ENTERPRISE = "enterprise"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PlanLimits:
|
|
30
|
+
"""Rate limits and quotas for each plan tier."""
|
|
31
|
+
requests_per_minute: int
|
|
32
|
+
requests_per_hour: int
|
|
33
|
+
requests_per_month: int
|
|
34
|
+
concurrent_sessions: int
|
|
35
|
+
max_session_duration_seconds: int
|
|
36
|
+
priority: int # Higher = faster queue priority
|
|
37
|
+
features: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PLAN_CONFIGS: dict[Plan, PlanLimits] = {
|
|
41
|
+
Plan.FREE: PlanLimits(
|
|
42
|
+
requests_per_minute=10,
|
|
43
|
+
requests_per_hour=100,
|
|
44
|
+
requests_per_month=200,
|
|
45
|
+
concurrent_sessions=1,
|
|
46
|
+
max_session_duration_seconds=300,
|
|
47
|
+
priority=0,
|
|
48
|
+
features=["real_time_analysis"],
|
|
49
|
+
),
|
|
50
|
+
Plan.STARTER: PlanLimits(
|
|
51
|
+
requests_per_minute=50,
|
|
52
|
+
requests_per_hour=500,
|
|
53
|
+
requests_per_month=1000,
|
|
54
|
+
concurrent_sessions=2,
|
|
55
|
+
max_session_duration_seconds=600,
|
|
56
|
+
priority=1,
|
|
57
|
+
features=["real_time_analysis", "basic_lead_scoring", "email_support", "dashboard_analytics"]
|
|
58
|
+
),
|
|
59
|
+
Plan.PROFESSIONAL: PlanLimits(
|
|
60
|
+
requests_per_minute=200,
|
|
61
|
+
requests_per_hour=2000,
|
|
62
|
+
requests_per_month=10000,
|
|
63
|
+
concurrent_sessions=10,
|
|
64
|
+
max_session_duration_seconds=1800,
|
|
65
|
+
priority=2,
|
|
66
|
+
features=["advanced_ai_coaching", "predictive_lead_scoring", "crm_integrations", "priority_support", "custom_training"]
|
|
67
|
+
),
|
|
68
|
+
Plan.ENTERPRISE: PlanLimits(
|
|
69
|
+
requests_per_minute=1000,
|
|
70
|
+
requests_per_hour=10000,
|
|
71
|
+
requests_per_month=100000,
|
|
72
|
+
concurrent_sessions=50,
|
|
73
|
+
max_session_duration_seconds=7200,
|
|
74
|
+
priority=3,
|
|
75
|
+
features=["white_label", "custom_ai_training", "dedicated_success_manager", "24_7_phone_support", "on_premise_deployment"]
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ApiKeyInfo:
|
|
82
|
+
"""Validated API key information."""
|
|
83
|
+
key_id: str
|
|
84
|
+
org_id: str
|
|
85
|
+
plan: Plan
|
|
86
|
+
limits: PlanLimits
|
|
87
|
+
created_at: int
|
|
88
|
+
is_active: bool
|
|
89
|
+
allowed_ips: list[str]
|
|
90
|
+
webhook_url: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# In-memory store for development; use Redis/DB in production
|
|
94
|
+
_api_keys: dict[str, dict] = {}
|
|
95
|
+
_logger = get_logger("api_keys")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _hash_key(api_key: str) -> str:
|
|
99
|
+
"""Hash API key for secure storage comparison."""
|
|
100
|
+
salt = os.getenv("API_KEY_SALT", "sales-voice-salt-change-in-production")
|
|
101
|
+
return hashlib.sha256(f"{salt}:{api_key}".encode()).hexdigest()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate_api_key(
|
|
105
|
+
org_id: str,
|
|
106
|
+
plan: Plan = Plan.FREE,
|
|
107
|
+
allowed_ips: Optional[list[str]] = None,
|
|
108
|
+
webhook_url: Optional[str] = None,
|
|
109
|
+
) -> tuple[str, str]:
|
|
110
|
+
"""
|
|
111
|
+
Generate a new API key for an organization.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
tuple: (api_key, key_id) - api_key should be shown once to user
|
|
115
|
+
"""
|
|
116
|
+
key_id = f"sk_{secrets.token_hex(8)}"
|
|
117
|
+
api_key = f"sv_{secrets.token_urlsafe(32)}"
|
|
118
|
+
key_hash = _hash_key(api_key)
|
|
119
|
+
|
|
120
|
+
_api_keys[key_hash] = {
|
|
121
|
+
"key_id": key_id,
|
|
122
|
+
"org_id": org_id,
|
|
123
|
+
"plan": plan.value,
|
|
124
|
+
"created_at": int(time.time()),
|
|
125
|
+
"is_active": True,
|
|
126
|
+
"allowed_ips": allowed_ips or [],
|
|
127
|
+
"webhook_url": webhook_url,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_logger.info(
|
|
131
|
+
"api_key_created",
|
|
132
|
+
key_id=key_id,
|
|
133
|
+
org_id=org_id,
|
|
134
|
+
plan=plan.value,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return api_key, key_id
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def validate_api_key(
|
|
141
|
+
redis,
|
|
142
|
+
api_key: str,
|
|
143
|
+
client_ip: Optional[str] = None,
|
|
144
|
+
) -> Optional[ApiKeyInfo]:
|
|
145
|
+
"""
|
|
146
|
+
Validate an API key and return its info if valid.
|
|
147
|
+
|
|
148
|
+
Checks:
|
|
149
|
+
1. Key exists and is active
|
|
150
|
+
2. IP allowlist (if configured)
|
|
151
|
+
3. Not revoked in Redis
|
|
152
|
+
"""
|
|
153
|
+
if not api_key or not api_key.startswith("sv_"):
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
key_hash = _hash_key(api_key)
|
|
157
|
+
|
|
158
|
+
# Check in-memory store first (for dev), then Redis
|
|
159
|
+
key_data = _api_keys.get(key_hash)
|
|
160
|
+
|
|
161
|
+
if not key_data:
|
|
162
|
+
# Try Redis for distributed storage
|
|
163
|
+
try:
|
|
164
|
+
stored = await redis.hgetall(f"apikey:{key_hash}")
|
|
165
|
+
if stored:
|
|
166
|
+
key_data = {
|
|
167
|
+
"key_id": stored.get("key_id"),
|
|
168
|
+
"org_id": stored.get("org_id"),
|
|
169
|
+
"plan": stored.get("plan", "free"),
|
|
170
|
+
"created_at": int(stored.get("created_at", 0)),
|
|
171
|
+
"is_active": stored.get("is_active", "true") == "true",
|
|
172
|
+
"allowed_ips": (stored.get("allowed_ips") or "").split(",") if stored.get("allowed_ips") else [],
|
|
173
|
+
"webhook_url": stored.get("webhook_url"),
|
|
174
|
+
}
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
if not key_data:
|
|
179
|
+
_logger.warning("api_key_not_found", key_prefix=api_key[:10])
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
if not key_data.get("is_active", True):
|
|
183
|
+
_logger.warning("api_key_inactive", key_id=key_data.get("key_id"))
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Check IP allowlist
|
|
187
|
+
allowed_ips = key_data.get("allowed_ips", [])
|
|
188
|
+
if allowed_ips and client_ip and client_ip not in allowed_ips:
|
|
189
|
+
_logger.warning(
|
|
190
|
+
"api_key_ip_denied",
|
|
191
|
+
key_id=key_data.get("key_id"),
|
|
192
|
+
client_ip=client_ip,
|
|
193
|
+
)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
# Check if revoked in Redis
|
|
197
|
+
try:
|
|
198
|
+
revoked = await redis.get(f"apikey:revoked:{key_data['key_id']}")
|
|
199
|
+
if revoked:
|
|
200
|
+
_logger.warning("api_key_revoked", key_id=key_data.get("key_id"))
|
|
201
|
+
return None
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
plan = Plan(key_data.get("plan", "free"))
|
|
206
|
+
|
|
207
|
+
return ApiKeyInfo(
|
|
208
|
+
key_id=key_data["key_id"],
|
|
209
|
+
org_id=key_data["org_id"],
|
|
210
|
+
plan=plan,
|
|
211
|
+
limits=PLAN_CONFIGS[plan],
|
|
212
|
+
created_at=key_data.get("created_at", 0),
|
|
213
|
+
is_active=True,
|
|
214
|
+
allowed_ips=allowed_ips,
|
|
215
|
+
webhook_url=key_data.get("webhook_url"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def revoke_api_key(redis, key_id: str) -> bool:
|
|
220
|
+
"""Revoke an API key by its ID."""
|
|
221
|
+
try:
|
|
222
|
+
await redis.set(f"apikey:revoked:{key_id}", "1", ex=86400 * 365) # 1 year TTL
|
|
223
|
+
_logger.info("api_key_revoked", key_id=key_id)
|
|
224
|
+
return True
|
|
225
|
+
except Exception as e:
|
|
226
|
+
_logger.error("api_key_revoke_failed", key_id=key_id, error=str(e))
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def generate_request_signature(
|
|
231
|
+
api_key: str,
|
|
232
|
+
timestamp: int,
|
|
233
|
+
body: bytes,
|
|
234
|
+
) -> str:
|
|
235
|
+
"""
|
|
236
|
+
Generate HMAC signature for request validation.
|
|
237
|
+
|
|
238
|
+
Used to verify request integrity and prevent replay attacks.
|
|
239
|
+
"""
|
|
240
|
+
message = f"{timestamp}.{body.decode('utf-8', errors='replace')}"
|
|
241
|
+
signature = hmac.new(
|
|
242
|
+
api_key.encode(),
|
|
243
|
+
message.encode(),
|
|
244
|
+
hashlib.sha256,
|
|
245
|
+
).hexdigest()
|
|
246
|
+
return f"v1={signature}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def verify_request_signature(
|
|
250
|
+
api_key: str,
|
|
251
|
+
timestamp: int,
|
|
252
|
+
body: bytes,
|
|
253
|
+
signature: str,
|
|
254
|
+
max_age_seconds: int = 300,
|
|
255
|
+
) -> bool:
|
|
256
|
+
"""
|
|
257
|
+
Verify request signature and timestamp.
|
|
258
|
+
|
|
259
|
+
Returns True if signature is valid and timestamp is fresh.
|
|
260
|
+
"""
|
|
261
|
+
# Check timestamp freshness
|
|
262
|
+
now = int(time.time())
|
|
263
|
+
if abs(now - timestamp) > max_age_seconds:
|
|
264
|
+
_logger.warning("signature_timestamp_expired", age=abs(now - timestamp))
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
expected = generate_request_signature(api_key, timestamp, body)
|
|
268
|
+
return hmac.compare_digest(expected, signature)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Bootstrap: create a master API key from environment if configured
|
|
272
|
+
def _bootstrap_master_key():
|
|
273
|
+
master_key = os.getenv("MASTER_API_KEY")
|
|
274
|
+
master_org = os.getenv("MASTER_ORG_ID", "system")
|
|
275
|
+
|
|
276
|
+
if master_key:
|
|
277
|
+
key_hash = _hash_key(master_key)
|
|
278
|
+
_api_keys[key_hash] = {
|
|
279
|
+
"key_id": "sk_master",
|
|
280
|
+
"org_id": master_org,
|
|
281
|
+
"plan": Plan.ENTERPRISE.value,
|
|
282
|
+
"created_at": int(time.time()),
|
|
283
|
+
"is_active": True,
|
|
284
|
+
"allowed_ips": [],
|
|
285
|
+
"webhook_url": None,
|
|
286
|
+
}
|
|
287
|
+
_logger.info("master_api_key_configured", org_id=master_org)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
_bootstrap_master_key()
|
app/auth/jwt.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Iterable, Optional
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
JWT_SECRET = os.getenv("VOICE_JWT_SECRET") or os.getenv("JWT_SECRET", "dev-secret-change-me")
|
|
15
|
+
JWT_ISSUER = os.getenv("JWT_ISSUER", "sales-voice")
|
|
16
|
+
JWT_AUDIENCE = os.getenv("JWT_AUDIENCE", "sales-voice")
|
|
17
|
+
JWT_LEEWAY_SECONDS = int(os.getenv("JWT_LEEWAY_SECONDS", "30"))
|
|
18
|
+
ENVIRONMENT = os.getenv("ENV", os.getenv("ENVIRONMENT", "development")).lower()
|
|
19
|
+
|
|
20
|
+
ACCESS_TTL_SECONDS = int(os.getenv("VOICE_ACCESS_TTL_SECONDS", "900"))
|
|
21
|
+
VOICE_TTL_SECONDS = int(os.getenv("VOICE_SESSION_TTL_SECONDS", "120"))
|
|
22
|
+
|
|
23
|
+
# Fail fast if default secret is used in non-dev environments
|
|
24
|
+
if ENVIRONMENT in {"prod", "production"} and JWT_SECRET == "dev-secret-change-me":
|
|
25
|
+
raise RuntimeError("JWT secret must be set in production")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _now() -> int:
|
|
29
|
+
return int(time.time())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _encode(payload: dict) -> str:
|
|
33
|
+
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def issue_access_token(
|
|
37
|
+
subject: str,
|
|
38
|
+
org: Optional[str],
|
|
39
|
+
plan: str,
|
|
40
|
+
scope: Iterable[str],
|
|
41
|
+
) -> tuple[str, int]:
|
|
42
|
+
now = _now()
|
|
43
|
+
payload = {
|
|
44
|
+
"sub": subject,
|
|
45
|
+
"org": org,
|
|
46
|
+
"plan": plan,
|
|
47
|
+
"scope": list(scope),
|
|
48
|
+
"type": "access",
|
|
49
|
+
"iat": now,
|
|
50
|
+
"exp": now + ACCESS_TTL_SECONDS,
|
|
51
|
+
"iss": JWT_ISSUER,
|
|
52
|
+
"aud": JWT_AUDIENCE,
|
|
53
|
+
}
|
|
54
|
+
return _encode(payload), ACCESS_TTL_SECONDS
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def issue_voice_token(
|
|
58
|
+
subject: str,
|
|
59
|
+
org: Optional[str],
|
|
60
|
+
plan: str,
|
|
61
|
+
scope: Iterable[str],
|
|
62
|
+
sid: str,
|
|
63
|
+
) -> tuple[str, int]:
|
|
64
|
+
now = _now()
|
|
65
|
+
payload = {
|
|
66
|
+
"sub": subject,
|
|
67
|
+
"org": org,
|
|
68
|
+
"plan": plan,
|
|
69
|
+
"scope": list(scope),
|
|
70
|
+
"sid": sid,
|
|
71
|
+
"type": "voice",
|
|
72
|
+
"iat": now,
|
|
73
|
+
"exp": now + VOICE_TTL_SECONDS,
|
|
74
|
+
"iss": JWT_ISSUER,
|
|
75
|
+
"aud": JWT_AUDIENCE,
|
|
76
|
+
}
|
|
77
|
+
return _encode(payload), VOICE_TTL_SECONDS
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def verify_token(token: str, required_scope: Optional[str] = None) -> dict:
|
|
81
|
+
try:
|
|
82
|
+
payload = jwt.decode(
|
|
83
|
+
token,
|
|
84
|
+
JWT_SECRET,
|
|
85
|
+
algorithms=["HS256"],
|
|
86
|
+
audience=JWT_AUDIENCE,
|
|
87
|
+
issuer=JWT_ISSUER,
|
|
88
|
+
leeway=JWT_LEEWAY_SECONDS,
|
|
89
|
+
options={
|
|
90
|
+
"require": ["sub", "exp", "iat", "iss", "aud", "type"],
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
except jwt.PyJWTError as exc:
|
|
94
|
+
raise AuthError("Invalid or expired token") from exc
|
|
95
|
+
|
|
96
|
+
token_type = payload.get("type")
|
|
97
|
+
if token_type not in {"access", "voice"}:
|
|
98
|
+
raise AuthError("Invalid token type")
|
|
99
|
+
|
|
100
|
+
scopes = set(payload.get("scope") or [])
|
|
101
|
+
if required_scope and required_scope not in scopes:
|
|
102
|
+
raise AuthError("Missing required scope")
|
|
103
|
+
return payload
|
app/auth/rate_limit.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QuotaError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def reserve_voice_session(
|
|
11
|
+
redis,
|
|
12
|
+
org_id: Optional[str],
|
|
13
|
+
sid: str,
|
|
14
|
+
limit: int,
|
|
15
|
+
ttl_seconds: int,
|
|
16
|
+
) -> None:
|
|
17
|
+
org_key = org_id or "unknown"
|
|
18
|
+
session_key = f"voice:session:{sid}"
|
|
19
|
+
count_key = f"voice:concurrency:{org_key}"
|
|
20
|
+
|
|
21
|
+
created = await redis.set(session_key, org_key, ex=ttl_seconds, nx=True)
|
|
22
|
+
if not created:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
count = await redis.incr(count_key)
|
|
26
|
+
if count == 1:
|
|
27
|
+
await redis.expire(count_key, ttl_seconds)
|
|
28
|
+
if count > limit:
|
|
29
|
+
await redis.decr(count_key)
|
|
30
|
+
await redis.delete(session_key)
|
|
31
|
+
raise QuotaError("Concurrent voice session limit reached")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def release_voice_session(redis, org_id: Optional[str], sid: str) -> None:
|
|
35
|
+
org_key = org_id or "unknown"
|
|
36
|
+
session_key = f"voice:session:{sid}"
|
|
37
|
+
count_key = f"voice:concurrency:{org_key}"
|
|
38
|
+
|
|
39
|
+
deleted = await redis.delete(session_key)
|
|
40
|
+
if deleted:
|
|
41
|
+
await redis.decr(count_key)
|