ccproxy-api 0.1.1__py3-none-any.whl → 0.1.3__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.
Files changed (107) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/__init__.py +1 -2
  3. ccproxy/adapters/openai/adapter.py +218 -180
  4. ccproxy/adapters/openai/streaming.py +247 -65
  5. ccproxy/api/__init__.py +0 -3
  6. ccproxy/api/app.py +173 -40
  7. ccproxy/api/dependencies.py +65 -3
  8. ccproxy/api/middleware/errors.py +3 -7
  9. ccproxy/api/middleware/headers.py +0 -2
  10. ccproxy/api/middleware/logging.py +4 -3
  11. ccproxy/api/middleware/request_content_logging.py +297 -0
  12. ccproxy/api/middleware/request_id.py +5 -0
  13. ccproxy/api/middleware/server_header.py +0 -4
  14. ccproxy/api/routes/__init__.py +9 -1
  15. ccproxy/api/routes/claude.py +23 -32
  16. ccproxy/api/routes/health.py +58 -4
  17. ccproxy/api/routes/mcp.py +171 -0
  18. ccproxy/api/routes/metrics.py +4 -8
  19. ccproxy/api/routes/permissions.py +217 -0
  20. ccproxy/api/routes/proxy.py +0 -53
  21. ccproxy/api/services/__init__.py +6 -0
  22. ccproxy/api/services/permission_service.py +368 -0
  23. ccproxy/api/ui/__init__.py +6 -0
  24. ccproxy/api/ui/permission_handler_protocol.py +33 -0
  25. ccproxy/api/ui/terminal_permission_handler.py +593 -0
  26. ccproxy/auth/conditional.py +2 -2
  27. ccproxy/auth/dependencies.py +1 -1
  28. ccproxy/auth/oauth/models.py +0 -1
  29. ccproxy/auth/oauth/routes.py +1 -3
  30. ccproxy/auth/storage/json_file.py +0 -1
  31. ccproxy/auth/storage/keyring.py +0 -3
  32. ccproxy/claude_sdk/__init__.py +2 -0
  33. ccproxy/claude_sdk/client.py +91 -8
  34. ccproxy/claude_sdk/converter.py +405 -210
  35. ccproxy/claude_sdk/options.py +88 -19
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -1
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +52 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +6 -17
  45. ccproxy/cli/options/claude_options.py +41 -1
  46. ccproxy/cli/options/core_options.py +0 -3
  47. ccproxy/cli/options/security_options.py +0 -2
  48. ccproxy/cli/options/server_options.py +3 -2
  49. ccproxy/config/auth.py +0 -1
  50. ccproxy/config/claude.py +78 -2
  51. ccproxy/config/discovery.py +0 -1
  52. ccproxy/config/docker_settings.py +0 -1
  53. ccproxy/config/loader.py +1 -4
  54. ccproxy/config/scheduler.py +20 -0
  55. ccproxy/config/security.py +7 -2
  56. ccproxy/config/server.py +5 -0
  57. ccproxy/config/settings.py +15 -7
  58. ccproxy/config/validators.py +1 -1
  59. ccproxy/core/async_utils.py +1 -4
  60. ccproxy/core/errors.py +45 -1
  61. ccproxy/core/http_transformers.py +4 -3
  62. ccproxy/core/interfaces.py +2 -2
  63. ccproxy/core/logging.py +97 -95
  64. ccproxy/core/middleware.py +1 -1
  65. ccproxy/core/proxy.py +1 -1
  66. ccproxy/core/transformers.py +1 -1
  67. ccproxy/core/types.py +1 -1
  68. ccproxy/docker/models.py +1 -1
  69. ccproxy/docker/protocol.py +0 -3
  70. ccproxy/models/__init__.py +41 -0
  71. ccproxy/models/claude_sdk.py +420 -0
  72. ccproxy/models/messages.py +45 -18
  73. ccproxy/models/permissions.py +115 -0
  74. ccproxy/models/requests.py +1 -1
  75. ccproxy/models/responses.py +64 -1
  76. ccproxy/observability/access_logger.py +1 -2
  77. ccproxy/observability/context.py +17 -1
  78. ccproxy/observability/metrics.py +1 -3
  79. ccproxy/observability/pushgateway.py +0 -2
  80. ccproxy/observability/stats_printer.py +2 -4
  81. ccproxy/observability/storage/duckdb_simple.py +1 -1
  82. ccproxy/observability/storage/models.py +0 -1
  83. ccproxy/pricing/cache.py +0 -1
  84. ccproxy/pricing/loader.py +5 -21
  85. ccproxy/pricing/updater.py +0 -1
  86. ccproxy/scheduler/__init__.py +1 -0
  87. ccproxy/scheduler/core.py +6 -6
  88. ccproxy/scheduler/manager.py +35 -7
  89. ccproxy/scheduler/registry.py +1 -1
  90. ccproxy/scheduler/tasks.py +127 -2
  91. ccproxy/services/claude_sdk_service.py +225 -329
  92. ccproxy/services/credentials/manager.py +0 -1
  93. ccproxy/services/credentials/oauth_client.py +1 -2
  94. ccproxy/services/proxy_service.py +93 -222
  95. ccproxy/testing/config.py +1 -1
  96. ccproxy/testing/mock_responses.py +0 -1
  97. ccproxy/utils/model_mapping.py +197 -0
  98. ccproxy/utils/models_provider.py +150 -0
  99. ccproxy/utils/simple_request_logger.py +284 -0
  100. ccproxy/utils/version_checker.py +184 -0
  101. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
  102. ccproxy_api-0.1.3.dist-info/RECORD +166 -0
  103. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
  104. ccproxy_api-0.1.1.dist-info/RECORD +0 -149
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal
4
4
 
5
5
  from pydantic import BaseModel, ConfigDict, Field
6
6
 
7
- from .requests import MessageContent, Usage
7
+ from .requests import Usage
8
8
 
9
9
 
10
10
  class ToolCall(BaseModel):
@@ -143,6 +143,69 @@ class APIError(BaseModel):
143
143
  )
144
144
 
145
145
 
146
+ class PermissionToolAllowResponse(BaseModel):
147
+ """Response model for allowed permission tool requests."""
148
+
149
+ behavior: Annotated[Literal["allow"], Field(description="Permission behavior")] = (
150
+ "allow"
151
+ )
152
+ updated_input: Annotated[
153
+ dict[str, Any],
154
+ Field(
155
+ description="Updated input parameters for the tool, or original input if unchanged",
156
+ alias="updatedInput",
157
+ ),
158
+ ]
159
+
160
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
161
+
162
+
163
+ class PermissionToolDenyResponse(BaseModel):
164
+ """Response model for denied permission tool requests."""
165
+
166
+ behavior: Annotated[Literal["deny"], Field(description="Permission behavior")] = (
167
+ "deny"
168
+ )
169
+ message: Annotated[
170
+ str,
171
+ Field(
172
+ description="Human-readable explanation of why the permission was denied"
173
+ ),
174
+ ]
175
+
176
+ model_config = ConfigDict(extra="forbid")
177
+
178
+
179
+ class PermissionToolPendingResponse(BaseModel):
180
+ """Response model for pending permission tool requests requiring user confirmation."""
181
+
182
+ behavior: Annotated[
183
+ Literal["pending"], Field(description="Permission behavior")
184
+ ] = "pending"
185
+ confirmation_id: Annotated[
186
+ str,
187
+ Field(
188
+ description="Unique identifier for the confirmation request",
189
+ alias="confirmationId",
190
+ ),
191
+ ]
192
+ message: Annotated[
193
+ str,
194
+ Field(
195
+ description="Instructions for retrying the request after user confirmation"
196
+ ),
197
+ ] = "User confirmation required. Please retry with the same confirmation_id."
198
+
199
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
200
+
201
+
202
+ PermissionToolResponse = (
203
+ PermissionToolAllowResponse
204
+ | PermissionToolDenyResponse
205
+ | PermissionToolPendingResponse
206
+ )
207
+
208
+
146
209
  class RateLimitError(APIError):
147
210
  """Rate limit error."""
148
211
 
@@ -7,9 +7,8 @@ access logs with complete request metadata including token usage and costs.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import asyncio
11
10
  import time
12
- from typing import TYPE_CHECKING, Any, Optional
11
+ from typing import TYPE_CHECKING, Any
13
12
 
14
13
  import structlog
15
14
 
@@ -21,7 +21,8 @@ import uuid
21
21
  from collections.abc import AsyncGenerator
22
22
  from contextlib import asynccontextmanager
23
23
  from dataclasses import dataclass, field
24
- from typing import Any, Optional
24
+ from datetime import UTC, datetime
25
+ from typing import Any
25
26
 
26
27
  import structlog
27
28
 
@@ -43,6 +44,7 @@ class RequestContext:
43
44
  logger: structlog.BoundLogger
44
45
  metadata: dict[str, Any] = field(default_factory=dict)
45
46
  storage: Any | None = None # Optional DuckDB storage instance
47
+ log_timestamp: datetime | None = None # Datetime for consistent logging filenames
46
48
 
47
49
  @property
48
50
  def duration_ms(self) -> float:
@@ -66,12 +68,25 @@ class RequestContext:
66
68
  event, request_id=self.request_id, duration_ms=self.duration_ms, **kwargs
67
69
  )
68
70
 
71
+ def get_log_timestamp_prefix(self) -> str:
72
+ """Get timestamp prefix for consistent log filenames.
73
+
74
+ Returns:
75
+ Timestamp string in YYYYMMDDhhmmss format (UTC)
76
+ """
77
+ if self.log_timestamp:
78
+ return self.log_timestamp.strftime("%Y%m%d%H%M%S")
79
+ else:
80
+ # Fallback to current time if not set
81
+ return datetime.now(UTC).strftime("%Y%m%d%H%M%S")
82
+
69
83
 
70
84
  @asynccontextmanager
71
85
  async def request_context(
72
86
  request_id: str | None = None,
73
87
  storage: Any | None = None,
74
88
  metrics: Any | None = None,
89
+ log_timestamp: datetime | None = None,
75
90
  **initial_context: Any,
76
91
  ) -> AsyncGenerator[RequestContext, None]:
77
92
  """
@@ -124,6 +139,7 @@ async def request_context(
124
139
  logger=request_logger,
125
140
  metadata=dict(initial_context),
126
141
  storage=storage,
142
+ log_timestamp=log_timestamp,
127
143
  )
128
144
 
129
145
  try:
@@ -15,9 +15,7 @@ Key features:
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- import asyncio
19
- import logging
20
- from typing import Any, Optional, Union
18
+ from typing import Any
21
19
 
22
20
 
23
21
  try:
@@ -2,8 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
- import random
7
5
  import time
8
6
  from typing import Any
9
7
 
@@ -9,10 +9,9 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import time
12
- from collections.abc import Callable
13
12
  from dataclasses import dataclass
14
- from datetime import datetime, timedelta
15
- from typing import Any, Optional
13
+ from datetime import datetime
14
+ from typing import Any
16
15
 
17
16
  import structlog
18
17
 
@@ -524,7 +523,6 @@ class StatsCollector:
524
523
  from rich import box
525
524
  from rich.console import Console
526
525
  from rich.table import Table
527
- from rich.text import Text
528
526
 
529
527
  output_buffer = StringIO()
530
528
  console = Console(file=output_buffer, width=80, force_terminal=True)
@@ -10,7 +10,7 @@ import time
10
10
  from collections.abc import Sequence
11
11
  from datetime import datetime
12
12
  from pathlib import Path
13
- from typing import Any, Optional
13
+ from typing import Any
14
14
 
15
15
  import structlog
16
16
  from sqlalchemy import text
@@ -6,7 +6,6 @@ using SQLModel to ensure type safety and eliminate column name repetition.
6
6
  """
7
7
 
8
8
  from datetime import datetime
9
- from typing import Optional
10
9
 
11
10
  from sqlmodel import Field, SQLModel
12
11
 
ccproxy/pricing/cache.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import json
4
4
  import time
5
- from pathlib import Path
6
5
  from typing import Any
7
6
 
8
7
  import httpx
ccproxy/pricing/loader.py CHANGED
@@ -6,6 +6,8 @@ from typing import Any
6
6
  from pydantic import ValidationError
7
7
  from structlog import get_logger
8
8
 
9
+ from ccproxy.utils.model_mapping import get_claude_aliases_mapping, map_model_to_claude
10
+
9
11
  from .models import PricingData
10
12
 
11
13
 
@@ -15,22 +17,6 @@ logger = get_logger(__name__)
15
17
  class PricingLoader:
16
18
  """Loads and converts pricing data from LiteLLM format to internal format."""
17
19
 
18
- # Claude model name mappings for different versions
19
- CLAUDE_MODEL_MAPPINGS = {
20
- # Map versioned models to their canonical names
21
- "claude-3-5-sonnet-latest": "claude-3-5-sonnet-20241022",
22
- "claude-3-5-sonnet-20240620": "claude-3-5-sonnet-20240620",
23
- "claude-3-5-sonnet-20241022": "claude-3-5-sonnet-20241022",
24
- "claude-3-5-haiku-latest": "claude-3-5-haiku-20241022",
25
- "claude-3-5-haiku-20241022": "claude-3-5-haiku-20241022",
26
- "claude-3-opus": "claude-3-opus-20240229",
27
- "claude-3-opus-20240229": "claude-3-opus-20240229",
28
- "claude-3-sonnet": "claude-3-sonnet-20240229",
29
- "claude-3-sonnet-20240229": "claude-3-sonnet-20240229",
30
- "claude-3-haiku": "claude-3-haiku-20240307",
31
- "claude-3-haiku-20240307": "claude-3-haiku-20240307",
32
- }
33
-
34
20
  @staticmethod
35
21
  def extract_claude_models(
36
22
  litellm_data: dict[str, Any], verbose: bool = True
@@ -112,9 +98,7 @@ class PricingLoader:
112
98
  pricing["cache_read"] = Decimal(str(cache_read_cost * 1_000_000))
113
99
 
114
100
  # Map to canonical model name if needed
115
- canonical_name = PricingLoader.CLAUDE_MODEL_MAPPINGS.get(
116
- model_name, model_name
117
- )
101
+ canonical_name = map_model_to_claude(model_name)
118
102
  internal_format[canonical_name] = pricing
119
103
 
120
104
  if verbose:
@@ -252,7 +236,7 @@ class PricingLoader:
252
236
  Returns:
253
237
  Dictionary mapping aliases to canonical model names
254
238
  """
255
- return PricingLoader.CLAUDE_MODEL_MAPPINGS.copy()
239
+ return get_claude_aliases_mapping()
256
240
 
257
241
  @staticmethod
258
242
  def get_canonical_model_name(model_name: str) -> str:
@@ -264,4 +248,4 @@ class PricingLoader:
264
248
  Returns:
265
249
  Canonical model name
266
250
  """
267
- return PricingLoader.CLAUDE_MODEL_MAPPINGS.get(model_name, model_name)
251
+ return map_model_to_claude(model_name)
@@ -1,6 +1,5 @@
1
1
  """Pricing updater for managing periodic refresh of pricing data."""
2
2
 
3
- import asyncio
4
3
  from decimal import Decimal
5
4
  from typing import Any
6
5
 
@@ -32,6 +32,7 @@ from .tasks import (
32
32
  __all__ = [
33
33
  "Scheduler",
34
34
  "TaskRegistry",
35
+ "register_task",
35
36
  "BaseScheduledTask",
36
37
  "PushgatewayTask",
37
38
  "StatsPrintingTask",
ccproxy/scheduler/core.py CHANGED
@@ -5,7 +5,7 @@ from typing import Any
5
5
 
6
6
  import structlog
7
7
 
8
- from .exceptions import (
8
+ from .errors import (
9
9
  SchedulerError,
10
10
  SchedulerShutdownError,
11
11
  TaskNotFoundError,
@@ -60,7 +60,7 @@ class Scheduler:
60
60
  self._running = True
61
61
  self._semaphore = asyncio.Semaphore(self.max_concurrent_tasks)
62
62
 
63
- logger.info(
63
+ logger.debug(
64
64
  "scheduler_starting",
65
65
  max_concurrent_tasks=self.max_concurrent_tasks,
66
66
  registered_tasks=self.task_registry.list_tasks(),
@@ -68,7 +68,7 @@ class Scheduler:
68
68
 
69
69
  try:
70
70
  # No automatic task creation - tasks must be explicitly added
71
- logger.info(
71
+ logger.debug(
72
72
  "scheduler_started",
73
73
  active_tasks=len(self._tasks),
74
74
  running_tasks=[
@@ -129,7 +129,7 @@ class Scheduler:
129
129
  ) from e
130
130
 
131
131
  self._tasks.clear()
132
- logger.info("scheduler_stopped")
132
+ logger.debug("scheduler_stopped")
133
133
 
134
134
  async def add_task(
135
135
  self,
@@ -166,13 +166,13 @@ class Scheduler:
166
166
  # Start the task if scheduler is running and task is enabled
167
167
  if self._running and task_instance.enabled:
168
168
  await task_instance.start()
169
- logger.info(
169
+ logger.debug(
170
170
  "task_added_and_started",
171
171
  task_name=task_name,
172
172
  task_type=task_type,
173
173
  )
174
174
  else:
175
- logger.info(
175
+ logger.debug(
176
176
  "task_added_not_started",
177
177
  task_name=task_name,
178
178
  task_type=task_type,
@@ -1,15 +1,17 @@
1
1
  """Scheduler management for FastAPI integration."""
2
2
 
3
- import asyncio
4
- from typing import Any
5
-
6
3
  import structlog
7
4
 
8
5
  from ccproxy.config.settings import Settings
9
6
 
10
- from .core import Scheduler, get_scheduler
7
+ from .core import Scheduler
11
8
  from .registry import register_task
12
- from .tasks import PricingCacheUpdateTask, PushgatewayTask, StatsPrintingTask
9
+ from .tasks import (
10
+ PricingCacheUpdateTask,
11
+ PushgatewayTask,
12
+ StatsPrintingTask,
13
+ VersionUpdateCheckTask,
14
+ )
13
15
 
14
16
 
15
17
  logger = structlog.get_logger(__name__)
@@ -83,7 +85,7 @@ async def setup_scheduler_tasks(scheduler: Scheduler, settings: Settings) -> Non
83
85
  enabled=True,
84
86
  force_refresh_on_startup=scheduler_config.pricing_force_refresh_on_startup,
85
87
  )
86
- logger.info(
88
+ logger.debug(
87
89
  "pricing_update_task_added",
88
90
  interval_hours=scheduler_config.pricing_update_interval_hours,
89
91
  force_refresh_on_startup=scheduler_config.pricing_force_refresh_on_startup,
@@ -95,6 +97,31 @@ async def setup_scheduler_tasks(scheduler: Scheduler, settings: Settings) -> Non
95
97
  error_type=type(e).__name__,
96
98
  )
97
99
 
100
+ # Add version update check task if enabled
101
+ if scheduler_config.version_check_enabled:
102
+ try:
103
+ # Convert hours to seconds
104
+ interval_seconds = scheduler_config.version_check_interval_hours * 3600
105
+
106
+ await scheduler.add_task(
107
+ task_name="version_update_check",
108
+ task_type="version_update_check",
109
+ interval_seconds=interval_seconds,
110
+ enabled=True,
111
+ startup_max_age_hours=scheduler_config.version_check_startup_max_age_hours,
112
+ )
113
+ logger.debug(
114
+ "version_check_task_added",
115
+ interval_hours=scheduler_config.version_check_interval_hours,
116
+ startup_max_age_hours=scheduler_config.version_check_startup_max_age_hours,
117
+ )
118
+ except Exception as e:
119
+ logger.error(
120
+ "version_check_task_add_failed",
121
+ error=str(e),
122
+ error_type=type(e).__name__,
123
+ )
124
+
98
125
 
99
126
  def _register_default_tasks() -> None:
100
127
  """Register default task types in the global registry."""
@@ -109,6 +136,8 @@ def _register_default_tasks() -> None:
109
136
  register_task("stats_printing", StatsPrintingTask)
110
137
  if not registry.is_registered("pricing_cache_update"):
111
138
  register_task("pricing_cache_update", PricingCacheUpdateTask)
139
+ if not registry.is_registered("version_update_check"):
140
+ register_task("version_update_check", VersionUpdateCheckTask)
112
141
 
113
142
 
114
143
  async def start_scheduler(settings: Settings) -> Scheduler | None:
@@ -177,7 +206,6 @@ async def stop_scheduler(scheduler: Scheduler | None) -> None:
177
206
 
178
207
  try:
179
208
  await scheduler.stop()
180
- logger.info("scheduler_stopped")
181
209
  except Exception as e:
182
210
  logger.error(
183
211
  "scheduler_stop_failed",
@@ -4,7 +4,7 @@ from typing import Any
4
4
 
5
5
  import structlog
6
6
 
7
- from .exceptions import TaskRegistrationError
7
+ from .errors import TaskRegistrationError
8
8
  from .tasks import BaseScheduledTask
9
9
 
10
10
 
@@ -5,12 +5,11 @@ import contextlib
5
5
  import random
6
6
  import time
7
7
  from abc import ABC, abstractmethod
8
+ from datetime import UTC
8
9
  from typing import Any
9
10
 
10
11
  import structlog
11
12
 
12
- from .exceptions import TaskExecutionError
13
-
14
13
 
15
14
  logger = structlog.get_logger(__name__)
16
15
 
@@ -482,3 +481,129 @@ class PricingCacheUpdateTask(BaseScheduledTask):
482
481
  error_type=type(e).__name__,
483
482
  )
484
483
  return False
484
+
485
+
486
+ class VersionUpdateCheckTask(BaseScheduledTask):
487
+ """Task for checking version updates periodically."""
488
+
489
+ def __init__(
490
+ self,
491
+ name: str,
492
+ interval_seconds: float,
493
+ enabled: bool = True,
494
+ startup_max_age_hours: float = 1.0,
495
+ ):
496
+ """
497
+ Initialize version update check task.
498
+
499
+ Args:
500
+ name: Task name
501
+ interval_seconds: Interval between version checks
502
+ enabled: Whether task is enabled
503
+ startup_max_age_hours: Maximum age in hours before running startup check
504
+ """
505
+ super().__init__(
506
+ name=name,
507
+ interval_seconds=interval_seconds,
508
+ enabled=enabled,
509
+ )
510
+ self.startup_max_age_hours = startup_max_age_hours
511
+ self._first_run = True
512
+
513
+ async def run(self) -> bool:
514
+ """Execute version update check."""
515
+ try:
516
+ from datetime import datetime
517
+
518
+ from ccproxy.utils.version_checker import (
519
+ VersionCheckState,
520
+ compare_versions,
521
+ fetch_latest_github_version,
522
+ get_current_version,
523
+ get_version_check_state_path,
524
+ load_check_state,
525
+ save_check_state,
526
+ )
527
+
528
+ state_path = get_version_check_state_path()
529
+ current_time = datetime.now(UTC)
530
+
531
+ # Check if we should run based on startup logic
532
+ if self._first_run:
533
+ self._first_run = False
534
+ should_run_startup_check = False
535
+
536
+ # Load existing state if available
537
+ existing_state = await load_check_state(state_path)
538
+ if existing_state:
539
+ # Check age of last check
540
+ time_diff = current_time - existing_state.last_check_at
541
+ age_hours = time_diff.total_seconds() / 3600
542
+
543
+ if age_hours > self.startup_max_age_hours:
544
+ should_run_startup_check = True
545
+ logger.debug(
546
+ "version_check_startup_needed",
547
+ task_name=self.name,
548
+ age_hours=age_hours,
549
+ max_age_hours=self.startup_max_age_hours,
550
+ )
551
+ else:
552
+ logger.debug(
553
+ "version_check_startup_skipped",
554
+ task_name=self.name,
555
+ age_hours=age_hours,
556
+ max_age_hours=self.startup_max_age_hours,
557
+ )
558
+ return True # Skip this run
559
+ else:
560
+ # No previous state, run check
561
+ should_run_startup_check = True
562
+ logger.debug("version_check_startup_no_state", task_name=self.name)
563
+
564
+ if not should_run_startup_check:
565
+ return True
566
+
567
+ # Fetch latest version from GitHub
568
+ latest_version = await fetch_latest_github_version()
569
+ if latest_version is None:
570
+ logger.warning("version_check_fetch_failed", task_name=self.name)
571
+ return False
572
+
573
+ # Get current version
574
+ current_version = get_current_version()
575
+
576
+ # Save state
577
+ new_state = VersionCheckState(
578
+ last_check_at=current_time,
579
+ latest_version_found=latest_version,
580
+ )
581
+ await save_check_state(state_path, new_state)
582
+
583
+ # Compare versions
584
+ if compare_versions(current_version, latest_version):
585
+ logger.info(
586
+ "version_update_available",
587
+ task_name=self.name,
588
+ current_version=current_version,
589
+ latest_version=latest_version,
590
+ message=f"New version {latest_version} available! You are running {current_version}",
591
+ )
592
+ else:
593
+ logger.debug(
594
+ "version_check_complete_no_update",
595
+ task_name=self.name,
596
+ current_version=current_version,
597
+ latest_version=latest_version,
598
+ )
599
+
600
+ return True
601
+
602
+ except Exception as e:
603
+ logger.error(
604
+ "version_check_task_error",
605
+ task_name=self.name,
606
+ error=str(e),
607
+ error_type=type(e).__name__,
608
+ )
609
+ return False