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.
Files changed (48) hide show
  1. {argo_proxy-3.1.0/src/argo_proxy.egg-info → argo_proxy-3.1.2}/PKG-INFO +1 -1
  2. {argo_proxy-3.1.0 → argo_proxy-3.1.2/src/argo_proxy.egg-info}/PKG-INFO +1 -1
  3. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/SOURCES.txt +2 -1
  4. argo_proxy-3.1.2/src/argoproxy/__init__.py +1 -0
  5. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/app.py +65 -3
  6. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/handlers.py +21 -2
  7. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/parser.py +11 -0
  8. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/io.py +15 -1
  9. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/model.py +12 -0
  10. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/models.py +67 -38
  11. argo_proxy-3.1.2/tests/test_unix_socket.py +179 -0
  12. argo_proxy-3.1.0/src/argoproxy/__init__.py +0 -1
  13. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/LICENSE +0 -0
  14. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/README.md +0 -0
  15. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/pyproject.toml +0 -0
  16. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/setup.cfg +0 -0
  17. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/dependency_links.txt +0 -0
  18. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/entry_points.txt +0 -0
  19. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/requires.txt +0 -0
  20. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argo_proxy.egg-info/top_level.txt +0 -0
  21. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/_vendor/__init__.py +0 -0
  22. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/_vendor/semver.py +0 -0
  23. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/_vendor/yaml.py +0 -0
  24. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/__init__.py +0 -0
  25. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/cli/display.py +0 -0
  26. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/__init__.py +0 -0
  27. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/interactive.py +0 -0
  28. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/config/validation.py +0 -0
  29. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/dev_proxy.py +0 -0
  30. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/dispatch.py +0 -0
  31. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/extras.py +0 -0
  32. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/endpoints/passthrough.py +0 -0
  33. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/performance.py +0 -0
  34. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/py.typed +0 -0
  35. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/attack_logger.py +0 -0
  36. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/image_processing.py +0 -0
  37. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/logging.py +0 -0
  38. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/misc.py +0 -0
  39. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/models.py +0 -0
  40. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/tool_calls.py +0 -0
  41. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/src/argoproxy/utils/transports.py +0 -0
  42. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_chat_completions.py +0 -0
  43. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_embeddings.py +0 -0
  44. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_function_calling_multiple.py +0 -0
  45. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_function_calling_single.py +0 -0
  46. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_leaked_tool_parser.py +0 -0
  47. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/test/test_model_resolution.py +0 -0
  48. {argo_proxy-3.1.0 → argo_proxy-3.1.2}/tests/test_config_migrate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argo-proxy
3
- Version: 3.1.0
3
+ Version: 3.1.2
4
4
  Summary: Proxy server to Argo API, OpenAI format compatible
5
5
  Author-email: Peng Ding <oaklight@gmx.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argo-proxy
3
- Version: 3.1.0
3
+ Version: 3.1.2
4
4
  Summary: Proxy server to Argo API, OpenAI format compatible
5
5
  Author-email: Peng Ding <oaklight@gmx.com>
6
6
  License-Expression: MIT
@@ -41,4 +41,5 @@ test/test_function_calling_multiple.py
41
41
  test/test_function_calling_single.py
42
42
  test/test_leaked_tool_parser.py
43
43
  test/test_model_resolution.py
44
- tests/test_config_migrate.py
44
+ tests/test_config_migrate.py
45
+ tests/test_unix_socket.py
@@ -0,0 +1 @@
1
+ __version__ = "3.1.2"
@@ -331,9 +331,10 @@ def create_app():
331
331
  context="app",
332
332
  )
333
333
 
334
- # Only register health check, version, and dev proxy routes
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
- web.run_app(app, host=host, port=port)
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
- run(host=config_instance.host, port=config_instance.port)
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
- ["config_version", "user", "host", "port", "verbose", "log_to_file"],
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
- "gpt4olatest": "argo:gpt-4o-latest",
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
- "gpto1preview": ["argo:gpt-o1-preview", "argo:o1-preview"], # about to retire
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
- # claude
57
- "claudeopus4": ["argo:claude-opus-4", "argo:claude-4-opus"],
58
- "claudeopus47": ["argo:claude-opus-4.7", "argo:claude-4.7-opus"],
59
- "claudesonnet4": ["argo:claude-sonnet-4", "argo:claude-4-sonnet"],
60
- "claudesonnet37": ["argo:claude-sonnet-3.7", "argo:claude-3.7-sonnet"],
61
- "claudesonnet35v2": ["argo:claude-sonnet-3.5-v2", "argo:claude-3.5-sonnet-v2"],
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-4o",
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
- # # Start periodic refresh (default 24h)
554
- # self._refresh_task = asyncio.create_task(
555
- # self._periodic_refresh(interval_hours=24)
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
- # async def _periodic_refresh(self, interval_hours: float):
636
- # """Background task for periodic refresh"""
637
- # while True:
638
- # await asyncio.sleep(interval_hours * 3600)
639
- # try:
640
- # await self.refresh_availability()
641
- # except Exception as e:
642
- # logger.error(f"Periodic refresh failed: {str(e)}")
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-4o"
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