Skip to content

Commit cc59272

Browse files
pavanputhraclaude
andcommitted
Port main branch updates to refactored api/common/conserver structure
Backport 7 commits from main (5f3350c..e98a3df) into the split layout, adapting all file paths and imports from the old server/ structure. Changes ported: - Add shared openai_client.py (common/lib/) with get_openai_client() and get_async_openai_client() supporting OpenAI, Azure, and LiteLLM proxy - Refactor all OpenAI-using links and storage to use get_openai_client(): analyze, analyze_and_label, analyze_vcon, check_and_tag, detect_engagement, openai_transcribe, chatgpt_files, milvus - deepgram_link: add LiteLLM proxy path (transcribe_via_litellm), fix fd leak in audio temp file handling, make confidence check optional - wtf_transcribe: update for new vfun /wtf API — simplified create_wtf_analysis (pass response body directly), file-binary field, language option, diarize default→False, min-duration default→0, status 200 only - api: /config endpoint uses Configuration.get_config() instead of reading the YAML file directly - tests: add mock_get_client patches to analyze_and_label and detect_engagement tests; fix test_external_ingress to patch api.index_vcon instead of api.index_vcon_parties - docs: add Langfuse integration and OTel Collector fan-out documentation - .gitignore: add litellm_config.yaml (contains local credentials) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e98a3df commit cc59272

18 files changed

Lines changed: 422 additions & 222 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ tmp
2121
.qodo
2222

2323
traefik/
24-
redis_data/
24+
redis_data/
25+
litellm_config.yaml

api/api.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -954,7 +954,7 @@ async def get_vcon_count(
954954
async def get_config() -> JSONResponse:
955955
"""Get the current system configuration.
956956
957-
Reads and returns the configuration from the file specified in CONSERVER_CONFIG_FILE.
957+
Returns the current configuration via Configuration.get_config().
958958
959959
Returns:
960960
JSONResponse containing the configuration
@@ -963,8 +963,7 @@ async def get_config() -> JSONResponse:
963963
HTTPException: If there is an error reading the config file
964964
"""
965965
try:
966-
with open(os.getenv("CONSERVER_CONFIG_FILE"), "r") as f:
967-
config = yaml.safe_load(f)
966+
config = Configuration.get_config()
968967
return JSONResponse(content=config)
969968
except Exception as e:
970969
logger.error(f"Error reading config: {str(e)}")

common/lib/openai_client.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""
2+
Shared OpenAI/Azure/LiteLLM client for vcon-server.
3+
4+
When LITELLM_PROXY_URL and LITELLM_MASTER_KEY are set in opts,
5+
returns an OpenAI client configured to use the LiteLLM proxy. Otherwise uses
6+
direct OpenAI or Azure OpenAI credentials from opts.
7+
8+
All links and storage that call OpenAI should use get_openai_client(opts) so
9+
LLM provider and proxy can be switched in one place.
10+
"""
11+
from openai import OpenAI, AzureOpenAI, AsyncOpenAI, AsyncAzureOpenAI
12+
13+
from lib.logging_utils import init_logger
14+
15+
logger = init_logger(__name__)
16+
17+
# Default Azure API version when not specified
18+
DEFAULT_AZURE_OPENAI_API_VERSION = "2024-10-21"
19+
20+
21+
def get_openai_client(opts=None):
22+
"""
23+
Return an OpenAI-compatible client (OpenAI or AzureOpenAI).
24+
Same client is used for chat and embeddings; LiteLLM proxy supports both.
25+
26+
opts: dict of options. All values are read from opts only.
27+
28+
Supported keys in opts:
29+
- LITELLM_PROXY_URL, LITELLM_MASTER_KEY -> use LiteLLM proxy (chat + embeddings)
30+
- AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_API_VERSION -> Azure
31+
- OPENAI_API_KEY or openai_api_key or api_key -> OpenAI
32+
- organization / organization_key, project / project_key (optional)
33+
"""
34+
opts = opts or {}
35+
36+
litellm_url = (opts.get("LITELLM_PROXY_URL") or "").strip().rstrip("/")
37+
litellm_key = (opts.get("LITELLM_MASTER_KEY") or "").strip()
38+
39+
if litellm_url and litellm_key:
40+
logger.info("Using LiteLLM proxy at %s", litellm_url)
41+
organization = opts.get("organization") or opts.get("organization_key")
42+
project = opts.get("project") or opts.get("project_key")
43+
return OpenAI(
44+
api_key=litellm_key,
45+
base_url=litellm_url,
46+
organization=organization if organization else None,
47+
project=project if project else None,
48+
timeout=120.0,
49+
max_retries=0,
50+
)
51+
52+
azure_endpoint = (opts.get("AZURE_OPENAI_ENDPOINT") or "").strip()
53+
azure_api_key = (opts.get("AZURE_OPENAI_API_KEY") or "").strip()
54+
azure_api_version = opts.get("AZURE_OPENAI_API_VERSION") or DEFAULT_AZURE_OPENAI_API_VERSION
55+
56+
if azure_endpoint and azure_api_key:
57+
logger.info("Using Azure OpenAI client at endpoint: %s", azure_endpoint)
58+
return AzureOpenAI(
59+
api_key=azure_api_key,
60+
azure_endpoint=azure_endpoint,
61+
api_version=azure_api_version,
62+
timeout=120.0,
63+
max_retries=0,
64+
)
65+
66+
openai_api_key = (
67+
opts.get("OPENAI_API_KEY")
68+
or opts.get("openai_api_key")
69+
or opts.get("api_key")
70+
)
71+
if openai_api_key:
72+
logger.info("Using public OpenAI client")
73+
organization = opts.get("organization") or opts.get("organization_key")
74+
project = opts.get("project") or opts.get("project_key")
75+
return OpenAI(
76+
api_key=openai_api_key,
77+
organization=organization if organization else None,
78+
project=project if project else None,
79+
timeout=120.0,
80+
max_retries=0,
81+
)
82+
83+
raise ValueError(
84+
"Set LITELLM_PROXY_URL + LITELLM_MASTER_KEY, or "
85+
"AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_API_KEY, or OPENAI_API_KEY (or api_key)"
86+
)
87+
88+
89+
def get_async_openai_client(opts=None):
90+
"""
91+
Return an async OpenAI-compatible client. Same opts semantics as get_openai_client.
92+
LiteLLM proxy is used for both chat and embeddings when configured.
93+
"""
94+
opts = opts or {}
95+
96+
litellm_url = (opts.get("LITELLM_PROXY_URL") or "").strip().rstrip("/")
97+
litellm_key = (opts.get("LITELLM_MASTER_KEY") or "").strip()
98+
if litellm_url and litellm_key:
99+
logger.info("Using LiteLLM proxy at %s (async)", litellm_url)
100+
return AsyncOpenAI(
101+
api_key=litellm_key,
102+
base_url=litellm_url + "/v1",
103+
timeout=120.0,
104+
max_retries=0,
105+
)
106+
107+
azure_endpoint = (opts.get("AZURE_OPENAI_ENDPOINT") or "").strip()
108+
azure_api_key = (opts.get("AZURE_OPENAI_API_KEY") or "").strip()
109+
azure_api_version = opts.get("AZURE_OPENAI_API_VERSION") or DEFAULT_AZURE_OPENAI_API_VERSION
110+
if azure_endpoint and azure_api_key:
111+
logger.info("Using Azure OpenAI client at endpoint: %s (async)", azure_endpoint)
112+
return AsyncAzureOpenAI(
113+
api_key=azure_api_key,
114+
azure_endpoint=azure_endpoint,
115+
api_version=azure_api_version,
116+
timeout=120.0,
117+
max_retries=0,
118+
)
119+
120+
openai_api_key = (
121+
opts.get("OPENAI_API_KEY")
122+
or opts.get("openai_api_key")
123+
or opts.get("api_key")
124+
)
125+
if openai_api_key:
126+
logger.info("Using public OpenAI client (async)")
127+
return AsyncOpenAI(api_key=openai_api_key, timeout=120.0, max_retries=0)
128+
129+
raise ValueError(
130+
"Set LITELLM_PROXY_URL + LITELLM_MASTER_KEY, or "
131+
"AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_API_KEY, or OPENAI_API_KEY (or api_key)"
132+
)

common/storage/chatgpt_files/__init__.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from lib.logging_utils import init_logger
2+
from lib.openai_client import get_openai_client
23
import json
34
import os
45
import redis_mgr
5-
from openai import OpenAI
66

77
logger = init_logger(__name__)
88

@@ -29,12 +29,9 @@ def save(vcon_uuid: str, options: dict = default_options) -> None:
2929
file_name = f"{vcon_uuid}.vcon.json"
3030
with open(file_name, "w") as file:
3131
json.dump(vcon, file)
32-
client = OpenAI(
33-
organization=options["organization_key"],
34-
project=options["project_key"],
35-
api_key=options["api_key"],
36-
)
37-
file = client.files.create(file=open(file_name, "rb"), purpose=options["purpose"])
32+
client = get_openai_client(options)
33+
with open(file_name, "rb") as upload_file:
34+
file = client.files.create(file=upload_file, purpose=options["purpose"])
3835
os.remove(file_name)
3936
client.beta.vector_stores.files.create(vector_store_id=options["vector_store_id"], file_id=file.id)
4037
except Exception as error:

common/storage/milvus/__init__.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
try:
1616
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
17-
from openai import OpenAI
17+
from lib.openai_client import get_openai_client
1818
except ImportError:
1919
logging.error("Required packages not found. Install with: pip install pymilvus openai")
2020
raise
@@ -446,12 +446,9 @@ def save(vcon_uuid: str, opts=default_options) -> None:
446446
logger.info(f"vCon {vcon_uuid} already exists in Milvus collection {collection_name}, skipping")
447447
return
448448

449-
# Initialize OpenAI client
450-
openai_client = OpenAI(
451-
api_key=opts["api_key"],
452-
organization=opts["organization"] if opts["organization"] else None
453-
)
454-
449+
# Initialize OpenAI client (supports LiteLLM proxy via LITELLM_PROXY_URL + LITELLM_MASTER_KEY provided in opts)
450+
openai_client = get_openai_client(opts)
451+
455452
# Extract text content from vCon
456453
text = extract_text_from_vcon(vcon_dict)
457454

common/tests/test_external_ingress.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ def test_validate_ingress_api_key_function(self):
3939

4040
@patch("config.Configuration.get_ingress_auth")
4141
@patch("api.add_vcon_to_set")
42-
@patch("api.index_vcon_parties")
42+
@patch("api.index_vcon")
4343
def test_successful_submission_single_api_key(
44-
self, mock_index_vcon_parties, mock_add_vcon_to_set, mock_get_ingress_auth
44+
self, mock_index_vcon, mock_add_vcon_to_set, mock_get_ingress_auth
4545
):
4646
"""Test successful vCon submission with single API key configuration."""
4747
# Configure mocks
@@ -89,9 +89,9 @@ def test_successful_submission_single_api_key(
8989

9090
@patch("config.Configuration.get_ingress_auth")
9191
@patch("api.add_vcon_to_set")
92-
@patch("api.index_vcon_parties")
92+
@patch("api.index_vcon")
9393
def test_successful_submission_multiple_api_keys(
94-
self, mock_index_vcon_parties, mock_add_vcon_to_set, mock_get_ingress_auth
94+
self, mock_index_vcon, mock_add_vcon_to_set, mock_get_ingress_auth
9595
):
9696
"""Test successful vCon submission with multiple API keys for same ingress."""
9797
# Configure mocks - multiple API keys for same ingress list

common/tests/test_post_vcon_expiry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def test_post_vcon_stores_without_default_expiry(
6969

7070
def test_post_vcon_expiry_value_is_3600(self):
7171
"""Test that the default expiry value is 3600 seconds (1 hour)."""
72+
# Verify the configured value
7273
assert VCON_REDIS_EXPIRY == 3600, "Default VCON_REDIS_EXPIRY should be 3600 seconds"
7374

7475
@patch("api.add_vcon_to_set")

conserver/links/analyze/__init__.py

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from lib.vcon_redis import VconRedis
22
from lib.logging_utils import init_logger
3+
from lib.openai_client import get_openai_client
34
import logging
4-
from openai import OpenAI, AzureOpenAI
55
from tenacity import (
66
retry,
77
stop_after_attempt,
@@ -98,24 +98,7 @@ def run(
9898
logger.info(f"Skipping {link_name} vCon {vcon_uuid} due to sampling")
9999
return vcon_uuid
100100

101-
# Extract credentials from options
102-
openai_api_key = opts.get("OPENAI_API_KEY")
103-
azure_openai_api_key = opts.get("AZURE_OPENAI_API_KEY")
104-
azure_openai_endpoint = opts.get("AZURE_OPENAI_ENDPOINT")
105-
api_version = opts.get("AZURE_OPENAI_API_VERSION")
106-
107-
client = None
108-
if openai_api_key:
109-
client = OpenAI(api_key=openai_api_key, timeout=120.0, max_retries=0)
110-
logger.info("Using public OpenAI client")
111-
elif azure_openai_api_key and azure_openai_endpoint:
112-
client = AzureOpenAI(api_key=azure_openai_api_key, azure_endpoint=azure_openai_endpoint, api_version=api_version)
113-
logger.info(f"Using Azure OpenAI client at endpoint:{azure_openai_endpoint}")
114-
else:
115-
raise ValueError(
116-
"OpenAI or Azure OpenAI credentials not provided. "
117-
"Need OPENAI_API_KEY or AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT"
118-
)
101+
client = get_openai_client(opts)
119102

120103
source_type = navigate_dict(opts, "source.analysis_type")
121104
text_location = navigate_dict(opts, "source.text_location")

conserver/links/analyze_and_label/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from lib.vcon_redis import VconRedis
22
from lib.logging_utils import init_logger
3+
from lib.openai_client import get_openai_client
34
import logging
45
import json
5-
from openai import OpenAI
66
from tenacity import (
77
retry,
88
stop_after_attempt,
@@ -79,7 +79,7 @@ def run(
7979
logger.info(f"Skipping {link_name} vCon {vcon_uuid} due to sampling")
8080
return vcon_uuid
8181

82-
client = OpenAI(api_key=opts["OPENAI_API_KEY"], timeout=120.0, max_retries=0)
82+
client = get_openai_client(opts)
8383
source_type = navigate_dict(opts, "source.analysis_type")
8484
text_location = navigate_dict(opts, "source.text_location")
8585

conserver/links/analyze_and_label/tests/test_analyze_and_label.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import json
33
import pytest
4-
from unittest.mock import patch, MagicMock
4+
from unittest.mock import patch, MagicMock, Mock
55

66
from server.links.analyze_and_label import run, generate_analysis_with_labels, get_analysis_for_type, navigate_dict
77
from server.vcon import Vcon
@@ -222,11 +222,13 @@ def test_navigate_dict():
222222
assert navigate_dict(test_dict, "z") is None
223223

224224

225+
@patch('server.links.analyze_and_label.get_openai_client')
225226
@patch('server.links.analyze_and_label.generate_analysis_with_labels')
226227
@patch('server.links.analyze_and_label.is_included', return_value=True)
227228
@patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True)
228-
def test_run_basic(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon):
229+
def test_run_basic(mock_sampling, mock_is_included, mock_generate_analysis, mock_get_client, mock_redis_with_vcon, sample_vcon):
229230
"""Test the basic run functionality with mocked analysis generation"""
231+
mock_get_client.return_value = Mock()
230232
# Set up mock to return analysis JSON
231233
mock_generate_analysis.return_value = json.dumps({
232234
"labels": ["customer_service", "billing_issue", "refund"]
@@ -264,12 +266,14 @@ def test_run_basic(mock_sampling, mock_is_included, mock_generate_analysis, mock
264266
assert "refund:refund" in tags_attachment["body"]
265267

266268

269+
@patch('server.links.analyze_and_label.get_openai_client')
267270
@patch('server.links.analyze_and_label.get_analysis_for_type')
268271
@patch('server.links.analyze_and_label.generate_analysis_with_labels')
269272
@patch('server.links.analyze_and_label.is_included', return_value=True)
270273
@patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True)
271-
def test_run_skip_existing_analysis(mock_sampling, mock_is_included, mock_generate_analysis, mock_get_analysis, mock_redis_with_vcon, sample_vcon_with_analysis):
274+
def test_run_skip_existing_analysis(mock_sampling, mock_is_included, mock_generate_analysis, mock_get_analysis, mock_get_client, mock_redis_with_vcon, sample_vcon_with_analysis):
272275
"""Test that run skips dialogs with existing labeled analysis"""
276+
mock_get_client.return_value = Mock()
273277
# Set up mock for generate_analysis_with_labels
274278
mock_generate_analysis.return_value = json.dumps({
275279
"labels": ["new_label_that_should_not_be_added"]
@@ -307,11 +311,13 @@ def test_run_skip_existing_analysis(mock_sampling, mock_is_included, mock_genera
307311
mock_generate_analysis.assert_not_called()
308312

309313

314+
@patch('server.links.analyze_and_label.get_openai_client')
310315
@patch('server.links.analyze_and_label.generate_analysis_with_labels')
311316
@patch('server.links.analyze_and_label.is_included', return_value=True)
312317
@patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True)
313-
def test_run_json_parse_error(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon):
318+
def test_run_json_parse_error(mock_sampling, mock_is_included, mock_generate_analysis, mock_get_client, mock_redis_with_vcon, sample_vcon):
314319
"""Test handling of JSON parse errors"""
320+
mock_get_client.return_value = Mock()
315321
# Set up mock to return invalid JSON
316322
mock_generate_analysis.return_value = "This is not valid JSON"
317323

@@ -338,11 +344,13 @@ def test_run_json_parse_error(mock_sampling, mock_is_included, mock_generate_ana
338344
assert tags_attachment is None or len(tags_attachment["body"]) == 0
339345

340346

347+
@patch('server.links.analyze_and_label.get_openai_client')
341348
@patch('server.links.analyze_and_label.generate_analysis_with_labels')
342349
@patch('server.links.analyze_and_label.is_included', return_value=True)
343350
@patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True)
344-
def test_run_analysis_exception(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon):
351+
def test_run_analysis_exception(mock_sampling, mock_is_included, mock_generate_analysis, mock_get_client, mock_redis_with_vcon, sample_vcon):
345352
"""Test handling of analysis generation exceptions"""
353+
mock_get_client.return_value = Mock()
346354
# Make analysis function raise an exception
347355
mock_generate_analysis.side_effect = Exception("Analysis generation failed")
348356

@@ -358,11 +366,13 @@ def test_run_analysis_exception(mock_sampling, mock_is_included, mock_generate_a
358366
run("test-uuid", "analyze_and_label", opts)
359367

360368

369+
@patch('server.links.analyze_and_label.get_openai_client')
361370
@patch('server.links.analyze_and_label.generate_analysis_with_labels')
362371
@patch('server.links.analyze_and_label.is_included', return_value=True)
363372
@patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True)
364-
def test_run_message_format(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon_message_format):
373+
def test_run_message_format(mock_sampling, mock_is_included, mock_generate_analysis, mock_get_client, mock_redis_with_vcon, sample_vcon_message_format):
365374
"""Test analyzing a dialog with message format"""
375+
mock_get_client.return_value = Mock()
366376
# Set up the mock Redis instance to return our sample vCon with message format
367377
mock_instance = mock_redis_with_vcon.return_value
368378
mock_instance.get_vcon.return_value = sample_vcon_message_format

0 commit comments

Comments
 (0)