argo-proxy 3.1.0__tar.gz → 3.1.2__tar.gz
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.
- {argo_proxy-3.1.0/src/argo_proxy.egg-info → argo_proxy-3.1.2}/PKG-INFO +1 -1
- {argo_proxy-3.1.0 → argo_proxy-3.1.2/src/argo_proxy.egg-info}/PKG-INFO +1 -1
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/SOURCES.txt +2 -1
- argo_proxy-3.1.2/src/argoproxy/__init__.py +1 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/app.py +65 -3
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/handlers.py +21 -2
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/parser.py +11 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/io.py +15 -1
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/model.py +12 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/models.py +67 -38
- argo_proxy-3.1.2/tests/test_unix_socket.py +179 -0
- argo_proxy-3.1.0/src/argoproxy/__init__.py +0 -1
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/LICENSE +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/README.md +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/pyproject.toml +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/setup.cfg +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/dependency_links.txt +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/entry_points.txt +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/requires.txt +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/top_level.txt +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/_vendor/__init__.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/_vendor/semver.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/_vendor/yaml.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/__init__.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/display.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/__init__.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/interactive.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/validation.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/dev_proxy.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/dispatch.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/extras.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/passthrough.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/performance.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/py.typed +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/attack_logger.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/image_processing.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/logging.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/misc.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/models.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/tool_calls.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/transports.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_chat_completions.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_embeddings.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_function_calling_multiple.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_function_calling_single.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_leaked_tool_parser.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_model_resolution.py +0 -0
- {argo_proxy-3.1.0 → argo_proxy-3.1.2}/tests/test_config_migrate.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.1.2"
|
|
@@ -331,9 +331,10 @@ def create_app():
|
|
|
331
331
|
context="app",
|
|
332
332
|
)
|
|
333
333
|
|
|
334
|
-
#
|
|
334
|
+
# Register basic utility routes available in dev mode
|
|
335
335
|
app.router.add_get("/health", health_check)
|
|
336
336
|
app.router.add_get("/version", get_version)
|
|
337
|
+
app.router.add_post("/refresh", refresh_models)
|
|
337
338
|
|
|
338
339
|
# Register dev proxy routes using a temporary config for URL resolution
|
|
339
340
|
dev_config = ArgoConfig()
|
|
@@ -366,7 +367,7 @@ def create_app():
|
|
|
366
367
|
return app
|
|
367
368
|
|
|
368
369
|
|
|
369
|
-
def run(*, host: str = "0.0.0.0", port: int = 8080):
|
|
370
|
+
def run(*, host: str = "0.0.0.0", port: int = 8080, socket: str = ""):
|
|
370
371
|
app = create_app()
|
|
371
372
|
|
|
372
373
|
# Add this to ensure signal handlers trigger a full shutdown
|
|
@@ -378,7 +379,68 @@ def run(*, host: str = "0.0.0.0", port: int = 8080):
|
|
|
378
379
|
signal.signal(sig, _force_exit)
|
|
379
380
|
|
|
380
381
|
try:
|
|
381
|
-
|
|
382
|
+
if socket:
|
|
383
|
+
_run_unix_socket(app, socket)
|
|
384
|
+
else:
|
|
385
|
+
web.run_app(app, host=host, port=port)
|
|
382
386
|
except Exception as e:
|
|
383
387
|
log_error(f"An error occurred while starting the server: {e}", context="app")
|
|
384
388
|
sys.exit(1)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _run_unix_socket(app: web.Application, socket_path: str):
|
|
392
|
+
"""Start the server listening on a Unix domain socket.
|
|
393
|
+
|
|
394
|
+
Removes any stale socket file, starts the app, then restricts
|
|
395
|
+
permissions to owner-only (0o600) so other users on a shared
|
|
396
|
+
host cannot connect.
|
|
397
|
+
"""
|
|
398
|
+
import stat
|
|
399
|
+
|
|
400
|
+
path = os.path.realpath(socket_path)
|
|
401
|
+
|
|
402
|
+
# Remove stale socket if present
|
|
403
|
+
if os.path.exists(path):
|
|
404
|
+
try:
|
|
405
|
+
st = os.stat(path)
|
|
406
|
+
if stat.S_ISSOCK(st.st_mode):
|
|
407
|
+
os.unlink(path)
|
|
408
|
+
log_info(f"Removed stale socket: {path}", context="app")
|
|
409
|
+
else:
|
|
410
|
+
log_error(
|
|
411
|
+
f"Socket path exists and is not a socket: {path}", context="app"
|
|
412
|
+
)
|
|
413
|
+
sys.exit(1)
|
|
414
|
+
except OSError as e:
|
|
415
|
+
log_error(f"Cannot remove stale socket {path}: {e}", context="app")
|
|
416
|
+
sys.exit(1)
|
|
417
|
+
|
|
418
|
+
# Ensure parent directory exists
|
|
419
|
+
parent = os.path.dirname(path)
|
|
420
|
+
if not os.path.isdir(parent):
|
|
421
|
+
log_error(f"Socket parent directory does not exist: {parent}", context="app")
|
|
422
|
+
sys.exit(1)
|
|
423
|
+
|
|
424
|
+
async def _set_socket_permissions(app_inner: web.Application):
|
|
425
|
+
"""Set restrictive permissions on the socket after it is created."""
|
|
426
|
+
if os.path.exists(path):
|
|
427
|
+
os.chmod(path, 0o600)
|
|
428
|
+
log_info(
|
|
429
|
+
f"Socket permissions set to 0700 (owner-only): {path}",
|
|
430
|
+
context="app",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def _cleanup_socket(app_inner: web.Application):
|
|
434
|
+
"""Remove socket file on shutdown."""
|
|
435
|
+
if os.path.exists(path):
|
|
436
|
+
try:
|
|
437
|
+
os.unlink(path)
|
|
438
|
+
log_info(f"Removed socket: {path}", context="app")
|
|
439
|
+
except OSError:
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
app.on_startup.append(_set_socket_permissions)
|
|
443
|
+
app.on_shutdown.append(_cleanup_socket)
|
|
444
|
+
|
|
445
|
+
log_info(f"Starting server on unix socket: {path}", context="app")
|
|
446
|
+
web.run_app(app, path=path)
|
|
@@ -12,7 +12,7 @@ from .._vendor.semver import version_parse
|
|
|
12
12
|
from ..__init__ import __version__
|
|
13
13
|
from ..config import PATHS_TO_TRY, validate_config
|
|
14
14
|
from ..utils.attack_logger import get_attack_logger
|
|
15
|
-
from ..utils.logging import log_error
|
|
15
|
+
from ..utils.logging import log_error, log_info
|
|
16
16
|
from .display import CHANGELOG_URL, display_startup_banner
|
|
17
17
|
|
|
18
18
|
|
|
@@ -25,8 +25,12 @@ def set_config_envs(args: argparse.Namespace):
|
|
|
25
25
|
"""Set environment variables from serve CLI arguments."""
|
|
26
26
|
if args.config:
|
|
27
27
|
os.environ["CONFIG_PATH"] = args.config
|
|
28
|
+
if args.host:
|
|
29
|
+
os.environ["HOST"] = args.host
|
|
28
30
|
if args.port:
|
|
29
31
|
os.environ["PORT"] = str(args.port)
|
|
32
|
+
if getattr(args, "socket", None):
|
|
33
|
+
os.environ["SOCKET"] = args.socket
|
|
30
34
|
if args.verbose:
|
|
31
35
|
os.environ["VERBOSE"] = str(True)
|
|
32
36
|
if args.quiet:
|
|
@@ -73,7 +77,22 @@ def handle_serve(args: argparse.Namespace):
|
|
|
73
77
|
if config_path is not None:
|
|
74
78
|
get_attack_logger().set_config_path(config_path)
|
|
75
79
|
|
|
76
|
-
|
|
80
|
+
if config_instance.socket:
|
|
81
|
+
log_info(
|
|
82
|
+
f"🌐 Listening on unix://{config_instance.socket}",
|
|
83
|
+
context="cli",
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
log_info(
|
|
87
|
+
f"🌐 Listening on http://{config_instance.host}:{config_instance.port}",
|
|
88
|
+
context="cli",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
run(
|
|
92
|
+
host=config_instance.host,
|
|
93
|
+
port=config_instance.port,
|
|
94
|
+
socket=config_instance.socket,
|
|
95
|
+
)
|
|
77
96
|
except KeyError:
|
|
78
97
|
log_error("Port not specified in configuration file.", context="cli")
|
|
79
98
|
sys.exit(1)
|
|
@@ -40,6 +40,17 @@ def _add_serve_arguments(parser: argparse.ArgumentParser) -> None:
|
|
|
40
40
|
type=int,
|
|
41
41
|
help="Port number to bind the server to",
|
|
42
42
|
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--socket",
|
|
45
|
+
"-S",
|
|
46
|
+
type=str,
|
|
47
|
+
default=None,
|
|
48
|
+
help=(
|
|
49
|
+
"Unix socket path to listen on (overrides --host/--port).\n"
|
|
50
|
+
"Permissions are set to 0700 (owner-only) for security on\n"
|
|
51
|
+
"shared hosts. Example: /run/user/$(id -u)/argo-proxy.sock"
|
|
52
|
+
),
|
|
53
|
+
)
|
|
43
54
|
|
|
44
55
|
verbosity = parser.add_mutually_exclusive_group()
|
|
45
56
|
verbosity.add_argument(
|
|
@@ -34,7 +34,15 @@ def _format_config_yaml(data: dict) -> str:
|
|
|
34
34
|
groups: list[tuple[str, list[str]]] = [
|
|
35
35
|
(
|
|
36
36
|
"# Core settings",
|
|
37
|
-
[
|
|
37
|
+
[
|
|
38
|
+
"config_version",
|
|
39
|
+
"user",
|
|
40
|
+
"host",
|
|
41
|
+
"port",
|
|
42
|
+
"socket",
|
|
43
|
+
"verbose",
|
|
44
|
+
"log_to_file",
|
|
45
|
+
],
|
|
38
46
|
),
|
|
39
47
|
(
|
|
40
48
|
"# Upstream",
|
|
@@ -118,6 +126,12 @@ def save_config(
|
|
|
118
126
|
|
|
119
127
|
def _apply_env_overrides(config_data: ArgoConfig) -> ArgoConfig:
|
|
120
128
|
"""Apply environment variable overrides to the config."""
|
|
129
|
+
if env_host := os.getenv("HOST"):
|
|
130
|
+
config_data.host = env_host
|
|
131
|
+
|
|
132
|
+
if env_socket := os.getenv("SOCKET"):
|
|
133
|
+
config_data.socket = env_socket
|
|
134
|
+
|
|
121
135
|
if env_port := os.getenv("PORT"):
|
|
122
136
|
config_data.port = int(env_port)
|
|
123
137
|
|
|
@@ -30,6 +30,7 @@ class ArgoConfig:
|
|
|
30
30
|
# Configuration fields with default values
|
|
31
31
|
host: str = "0.0.0.0" # Default to 0.0.0.0
|
|
32
32
|
port: int = 44497
|
|
33
|
+
socket: str = "" # Unix socket path; overrides host:port when set
|
|
33
34
|
user: str = ""
|
|
34
35
|
verbose: bool = True
|
|
35
36
|
|
|
@@ -72,6 +73,9 @@ class ArgoConfig:
|
|
|
72
73
|
log_to_file: bool = False # Enable file logging alongside stdout
|
|
73
74
|
max_log_history: int = 3 # Keep last N messages in verbose request logs
|
|
74
75
|
|
|
76
|
+
# Model list auto-refresh
|
|
77
|
+
model_refresh_interval_hours: float = 24 # 0 to disable
|
|
78
|
+
|
|
75
79
|
# Image processing settings
|
|
76
80
|
enable_payload_control: bool = False # Enable automatic payload size control
|
|
77
81
|
max_payload_size: int = 20 # MB default (total for all images)
|
|
@@ -245,6 +249,10 @@ class ArgoConfig:
|
|
|
245
249
|
# Drop private fields
|
|
246
250
|
serialized = {k: v for k, v in serialized.items() if not k.startswith("_")}
|
|
247
251
|
|
|
252
|
+
# Only persist socket when explicitly set
|
|
253
|
+
if not self.socket:
|
|
254
|
+
serialized.pop("socket", None)
|
|
255
|
+
|
|
248
256
|
# Add the user-configurable base URL (stored as private field)
|
|
249
257
|
if self._argo_base_url:
|
|
250
258
|
serialized["argo_base_url"] = self.argo_base_url
|
|
@@ -259,6 +267,10 @@ class ArgoConfig:
|
|
|
259
267
|
if self._dump_dir:
|
|
260
268
|
serialized["dump_dir"] = self._dump_dir
|
|
261
269
|
|
|
270
|
+
# Persist model refresh interval only when non-default
|
|
271
|
+
if self.model_refresh_interval_hours == 24:
|
|
272
|
+
serialized.pop("model_refresh_interval_hours", None)
|
|
273
|
+
|
|
262
274
|
# Persist native URLs only when explicitly overridden (differ from
|
|
263
275
|
# the values that would be derived from argo_base_url)
|
|
264
276
|
base = self.argo_base_url
|
|
@@ -30,35 +30,43 @@ def flatten_mapping(mapping: dict[str, Any]) -> dict[str, str]:
|
|
|
30
30
|
return flat
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
# Default models fallback
|
|
33
|
+
# Default models fallback — kept in sync with the live /v1/models endpoint.
|
|
34
34
|
_DEFAULT_CHAT_MODELS = flatten_mapping(
|
|
35
35
|
{
|
|
36
|
-
# openai
|
|
37
|
-
"gpt35": "argo:gpt-3.5-turbo",
|
|
38
|
-
"gpt35large": "argo:gpt-3.5-turbo-16k",
|
|
39
|
-
"gpt4": "argo:gpt-4",
|
|
40
|
-
"gpt4large": "argo:gpt-4-32k",
|
|
41
|
-
"gpt4turbo": "argo:gpt-4-turbo",
|
|
36
|
+
# openai – gpt-4o (legacy, still served)
|
|
42
37
|
"gpt4o": "argo:gpt-4o",
|
|
43
|
-
|
|
44
|
-
"gpto1mini": ["argo:gpt-o1-mini", "argo:o1-mini"],
|
|
45
|
-
"gpto3mini": ["argo:gpt-o3-mini", "argo:o3-mini"],
|
|
38
|
+
# openai – o-series reasoning
|
|
46
39
|
"gpto1": ["argo:gpt-o1", "argo:o1"],
|
|
47
|
-
"
|
|
40
|
+
"gpto3mini": ["argo:gpt-o3-mini", "argo:o3-mini"],
|
|
48
41
|
"gpto3": ["argo:gpt-o3", "argo:o3"],
|
|
49
42
|
"gpto4mini": ["argo:gpt-o4-mini", "argo:o4-mini"],
|
|
43
|
+
# openai – gpt-4.1 family
|
|
50
44
|
"gpt41": "argo:gpt-4.1",
|
|
51
45
|
"gpt41mini": "argo:gpt-4.1-mini",
|
|
52
46
|
"gpt41nano": "argo:gpt-4.1-nano",
|
|
47
|
+
# openai – gpt-5 family
|
|
48
|
+
"gpt5": "argo:gpt-5",
|
|
49
|
+
"gpt5mini": "argo:gpt-5-mini",
|
|
50
|
+
"gpt5nano": "argo:gpt-5-nano",
|
|
51
|
+
"gpt51": "argo:gpt-5.1",
|
|
52
|
+
"gpt52": "argo:gpt-5.2",
|
|
53
|
+
"gpt54": "argo:gpt-5.4",
|
|
54
|
+
"gpt55": "argo:gpt-5.5",
|
|
53
55
|
# gemini
|
|
54
56
|
"gemini25pro": "argo:gemini-2.5-pro",
|
|
55
57
|
"gemini25flash": "argo:gemini-2.5-flash",
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
58
|
+
"gemini35flash": "argo:gemini-3.5-flash",
|
|
59
|
+
"gemini31flashlite": "argo:gemini-3.1-flash-lite",
|
|
60
|
+
# claude – opus
|
|
61
|
+
"claudeopus47": ["argo:claude-4.7-opus", "argo:claude-opus-4.7"],
|
|
62
|
+
"claudeopus46": ["argo:claude-4.6-opus", "argo:claude-opus-4.6"],
|
|
63
|
+
"claudeopus45": ["argo:claude-4.5-opus", "argo:claude-opus-4.5"],
|
|
64
|
+
"claudeopus41": ["argo:claude-4.1-opus", "argo:claude-opus-4.1"],
|
|
65
|
+
# claude – sonnet
|
|
66
|
+
"claudesonnet46": ["argo:claude-4.6-sonnet", "argo:claude-sonnet-4.6"],
|
|
67
|
+
"claudesonnet45": ["argo:claude-4.5-sonnet", "argo:claude-sonnet-4.5"],
|
|
68
|
+
# claude – haiku
|
|
69
|
+
"claudehaiku45": ["argo:claude-4.5-haiku", "argo:claude-haiku-4.5"],
|
|
62
70
|
}
|
|
63
71
|
)
|
|
64
72
|
|
|
@@ -84,11 +92,8 @@ def filter_model_by_patterns(
|
|
|
84
92
|
return sorted(matching)
|
|
85
93
|
|
|
86
94
|
|
|
87
|
-
# any models that unable to handle system prompt
|
|
88
|
-
NO_SYS_MSG_PATTERNS: set[str] =
|
|
89
|
-
"*o1preview", # Explicitly matches gpto1preview
|
|
90
|
-
"*o1mini", # Explicitly matches gpto1mini
|
|
91
|
-
}
|
|
95
|
+
# any models that unable to handle system prompt (o1-mini / o1-preview retired)
|
|
96
|
+
NO_SYS_MSG_PATTERNS: set[str] = set()
|
|
92
97
|
|
|
93
98
|
NO_SYS_MSG_MODELS = filter_model_by_patterns(
|
|
94
99
|
_DEFAULT_CHAT_MODELS,
|
|
@@ -123,11 +128,12 @@ NATIVE_TOOL_CALL_MODELS = filter_model_by_patterns(
|
|
|
123
128
|
)
|
|
124
129
|
|
|
125
130
|
TIKTOKEN_ENCODING_PREFIX_MAPPING = {
|
|
131
|
+
"gpt5": "o200k_base", # gpt-5 family
|
|
126
132
|
"gpto": "o200k_base", # o-series
|
|
127
133
|
"gpt4o": "o200k_base", # gpt-4o
|
|
134
|
+
"gpt41": "o200k_base", # gpt-4.1 family
|
|
128
135
|
# this order need to be preserved to correctly parse mapping
|
|
129
|
-
"gpt4": "cl100k_base", # gpt-4 series
|
|
130
|
-
"gpt3": "cl100k_base", # gpt-3 series
|
|
136
|
+
"gpt4": "cl100k_base", # gpt-4 series (legacy fallback)
|
|
131
137
|
"ada002": "cl100k_base", # embedding
|
|
132
138
|
"v3": "cl100k_base", # embedding
|
|
133
139
|
}
|
|
@@ -474,7 +480,7 @@ def _categorize_results(
|
|
|
474
480
|
unavailable.clear()
|
|
475
481
|
else:
|
|
476
482
|
log_error(
|
|
477
|
-
"Proceeding without unavailable models. Subsequent calls to these models will be replaced with argo:gpt-
|
|
483
|
+
"Proceeding without unavailable models. Subsequent calls to these models will be replaced with argo:gpt-5-nano",
|
|
478
484
|
context="models",
|
|
479
485
|
)
|
|
480
486
|
|
|
@@ -550,10 +556,16 @@ class ModelRegistry:
|
|
|
550
556
|
f"Initial availability check failed: {str(e)}", context="ModelRegistry"
|
|
551
557
|
)
|
|
552
558
|
|
|
553
|
-
#
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
559
|
+
# Start periodic refresh if configured
|
|
560
|
+
interval = self._config.model_refresh_interval_hours
|
|
561
|
+
if interval > 0:
|
|
562
|
+
self._refresh_task = asyncio.create_task(
|
|
563
|
+
self._periodic_refresh(interval_hours=interval)
|
|
564
|
+
)
|
|
565
|
+
log_info(
|
|
566
|
+
f"Periodic model refresh enabled: every {interval}h",
|
|
567
|
+
context="ModelRegistry",
|
|
568
|
+
)
|
|
557
569
|
|
|
558
570
|
async def refresh_availability(self, real_test: bool = False):
|
|
559
571
|
"""Refresh model availability status"""
|
|
@@ -632,14 +644,27 @@ class ModelRegistry:
|
|
|
632
644
|
"Falling back to default model list", context="ModelRegistry"
|
|
633
645
|
)
|
|
634
646
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
647
|
+
async def _periodic_refresh(self, interval_hours: float):
|
|
648
|
+
"""Background task that refreshes the model list on a fixed interval."""
|
|
649
|
+
try:
|
|
650
|
+
while True:
|
|
651
|
+
await asyncio.sleep(interval_hours * 3600)
|
|
652
|
+
log_info(
|
|
653
|
+
f"Periodic model refresh triggered (every {interval_hours}h)",
|
|
654
|
+
context="ModelRegistry",
|
|
655
|
+
)
|
|
656
|
+
try:
|
|
657
|
+
await self.refresh_availability()
|
|
658
|
+
except Exception as e:
|
|
659
|
+
log_error(
|
|
660
|
+
f"Periodic refresh failed: {e!s}",
|
|
661
|
+
context="ModelRegistry",
|
|
662
|
+
)
|
|
663
|
+
except asyncio.CancelledError:
|
|
664
|
+
log_debug(
|
|
665
|
+
"Periodic model refresh task cancelled",
|
|
666
|
+
context="ModelRegistry",
|
|
667
|
+
)
|
|
643
668
|
|
|
644
669
|
async def manual_refresh(self):
|
|
645
670
|
"""Trigger manual refresh of model data"""
|
|
@@ -756,9 +781,13 @@ class ModelRegistry:
|
|
|
756
781
|
return model_name
|
|
757
782
|
|
|
758
783
|
if model_type == "chat":
|
|
759
|
-
default_model = "argo:gpt-
|
|
784
|
+
default_model = "argo:gpt-5-nano"
|
|
760
785
|
elif model_type == "embed":
|
|
761
786
|
default_model = "argo:text-embedding-3-small"
|
|
787
|
+
log_warning(
|
|
788
|
+
f"Model '{model_name}' not found in registry, falling back to {default_model}",
|
|
789
|
+
context="ModelRegistry",
|
|
790
|
+
)
|
|
762
791
|
return self.available_models[default_model]
|
|
763
792
|
|
|
764
793
|
def as_openai_list(self) -> dict[str, Any]:
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Tests for Unix socket support in argo-proxy.
|
|
2
|
+
|
|
3
|
+
Covers the config model, CLI parser, IO formatting, and end-to-end
|
|
4
|
+
server lifecycle on a Unix domain socket.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import socket as sock_mod
|
|
10
|
+
import stat
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
import pytest
|
|
14
|
+
from aiohttp import web
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Fixtures
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def tmp_sock(tmp_path):
|
|
24
|
+
"""Return a path for a temporary socket (does not create it)."""
|
|
25
|
+
return str(tmp_path / "argo-proxy-test.sock")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Config model tests
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestArgoConfigSocket:
|
|
34
|
+
"""ArgoConfig socket field behavior."""
|
|
35
|
+
|
|
36
|
+
def test_default_empty(self):
|
|
37
|
+
from argoproxy.config.model import ArgoConfig
|
|
38
|
+
|
|
39
|
+
c = ArgoConfig()
|
|
40
|
+
assert c.socket == ""
|
|
41
|
+
|
|
42
|
+
def test_from_dict(self):
|
|
43
|
+
from argoproxy.config.model import ArgoConfig
|
|
44
|
+
|
|
45
|
+
c = ArgoConfig.from_dict({"socket": "/tmp/x.sock", "user": "t", "port": 9999})
|
|
46
|
+
assert c.socket == "/tmp/x.sock"
|
|
47
|
+
assert c.port == 9999
|
|
48
|
+
|
|
49
|
+
def test_persistent_dict_omits_when_empty(self):
|
|
50
|
+
from argoproxy.config.model import ArgoConfig
|
|
51
|
+
|
|
52
|
+
d = ArgoConfig().to_persistent_dict()
|
|
53
|
+
assert "socket" not in d
|
|
54
|
+
|
|
55
|
+
def test_persistent_dict_includes_when_set(self):
|
|
56
|
+
from argoproxy.config.model import ArgoConfig
|
|
57
|
+
|
|
58
|
+
c = ArgoConfig(socket="/run/user/1000/argo-proxy.sock")
|
|
59
|
+
d = c.to_persistent_dict()
|
|
60
|
+
assert d["socket"] == "/run/user/1000/argo-proxy.sock"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Config IO tests
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestConfigIO:
|
|
69
|
+
"""YAML formatting and env-override for socket field."""
|
|
70
|
+
|
|
71
|
+
def test_yaml_includes_socket_when_set(self):
|
|
72
|
+
from argoproxy.config.io import _format_config_yaml
|
|
73
|
+
from argoproxy.config.model import ArgoConfig
|
|
74
|
+
|
|
75
|
+
c = ArgoConfig(socket="/tmp/s.sock", user="test")
|
|
76
|
+
yaml_out = _format_config_yaml(c.to_persistent_dict())
|
|
77
|
+
assert "socket:" in yaml_out
|
|
78
|
+
|
|
79
|
+
def test_yaml_omits_socket_when_empty(self):
|
|
80
|
+
from argoproxy.config.io import _format_config_yaml
|
|
81
|
+
from argoproxy.config.model import ArgoConfig
|
|
82
|
+
|
|
83
|
+
c = ArgoConfig(user="test")
|
|
84
|
+
yaml_out = _format_config_yaml(c.to_persistent_dict())
|
|
85
|
+
assert "socket:" not in yaml_out
|
|
86
|
+
|
|
87
|
+
def test_env_override(self, monkeypatch):
|
|
88
|
+
from argoproxy.config.io import _apply_env_overrides
|
|
89
|
+
from argoproxy.config.model import ArgoConfig
|
|
90
|
+
|
|
91
|
+
monkeypatch.setenv("SOCKET", "/tmp/env.sock")
|
|
92
|
+
c = _apply_env_overrides(ArgoConfig(user="test"))
|
|
93
|
+
assert c.socket == "/tmp/env.sock"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# CLI parser tests
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestCLIParser:
|
|
102
|
+
"""--socket / -S flags on the serve subcommand."""
|
|
103
|
+
|
|
104
|
+
def test_long_flag(self):
|
|
105
|
+
from argoproxy.cli.parser import create_parser
|
|
106
|
+
|
|
107
|
+
args = create_parser().parse_args(["serve", "--socket", "/tmp/t.sock"])
|
|
108
|
+
assert args.socket == "/tmp/t.sock"
|
|
109
|
+
|
|
110
|
+
def test_short_flag(self):
|
|
111
|
+
from argoproxy.cli.parser import create_parser
|
|
112
|
+
|
|
113
|
+
args = create_parser().parse_args(["serve", "-S", "/tmp/s.sock"])
|
|
114
|
+
assert args.socket == "/tmp/s.sock"
|
|
115
|
+
|
|
116
|
+
def test_default_none(self):
|
|
117
|
+
from argoproxy.cli.parser import create_parser
|
|
118
|
+
|
|
119
|
+
args = create_parser().parse_args(["serve"])
|
|
120
|
+
assert args.socket is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# End-to-end unix socket server test
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestUnixSocketServer:
|
|
129
|
+
"""Start aiohttp on a unix socket, verify connectivity and permissions."""
|
|
130
|
+
|
|
131
|
+
def test_socket_lifecycle(self, tmp_sock):
|
|
132
|
+
"""Server listens on socket, responds to requests, permissions are 0700."""
|
|
133
|
+
|
|
134
|
+
async def _run():
|
|
135
|
+
async def health(request):
|
|
136
|
+
return web.json_response({"status": "healthy"})
|
|
137
|
+
|
|
138
|
+
app = web.Application()
|
|
139
|
+
app.router.add_get("/health", health)
|
|
140
|
+
|
|
141
|
+
runner = web.AppRunner(app)
|
|
142
|
+
await runner.setup()
|
|
143
|
+
site = web.UnixSite(runner, tmp_sock)
|
|
144
|
+
await site.start()
|
|
145
|
+
|
|
146
|
+
# Socket exists and is a socket
|
|
147
|
+
assert os.path.exists(tmp_sock)
|
|
148
|
+
assert stat.S_ISSOCK(os.stat(tmp_sock).st_mode)
|
|
149
|
+
|
|
150
|
+
# Set permissions like _run_unix_socket does
|
|
151
|
+
os.chmod(tmp_sock, 0o600)
|
|
152
|
+
assert os.stat(tmp_sock).st_mode & 0o777 == 0o600
|
|
153
|
+
|
|
154
|
+
# Hit the health endpoint
|
|
155
|
+
conn = aiohttp.UnixConnector(path=tmp_sock)
|
|
156
|
+
async with aiohttp.ClientSession(connector=conn) as session:
|
|
157
|
+
async with session.get("http://localhost/health") as resp:
|
|
158
|
+
assert resp.status == 200
|
|
159
|
+
body = await resp.json()
|
|
160
|
+
assert body == {"status": "healthy"}
|
|
161
|
+
|
|
162
|
+
await runner.cleanup()
|
|
163
|
+
|
|
164
|
+
asyncio.run(_run())
|
|
165
|
+
|
|
166
|
+
def test_stale_socket_removal(self, tmp_sock):
|
|
167
|
+
"""A stale socket file is detected and can be removed."""
|
|
168
|
+
# Create a real socket file (simulate stale)
|
|
169
|
+
s = sock_mod.socket(sock_mod.AF_UNIX, sock_mod.SOCK_STREAM)
|
|
170
|
+
s.bind(tmp_sock)
|
|
171
|
+
s.close()
|
|
172
|
+
|
|
173
|
+
assert os.path.exists(tmp_sock)
|
|
174
|
+
st = os.stat(tmp_sock)
|
|
175
|
+
assert stat.S_ISSOCK(st.st_mode)
|
|
176
|
+
|
|
177
|
+
# Our code removes stale sockets
|
|
178
|
+
os.unlink(tmp_sock)
|
|
179
|
+
assert not os.path.exists(tmp_sock)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "3.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|