Commit Graph
37 Commits
Author SHA1 Message Date
whit3rabbitandClaude Sonnet 4.6 8ac9ef2677 Merge feat/batch-engine-phase1 into main
Resolves conflicts: take HEAD (security audit) for mcp.rs imports,
register_server_blocking error handling, and maybe_execute_tools loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:00:37 -05:00
whit3rabbitandClaude Sonnet 4.6 ef5f0f596c refactor(proxy): wire batch handlers through BatchEngine crate
Proxy batch handlers now use BatchEngine for job lifecycle, file storage,
and webhook delivery instead of direct SQLite calls. Old batch/db.rs
stripped to Anthropic-specific mapping only. Cancel endpoint at
POST /v1/batches/{id}/cancel. BatchEngine initialized in main.rs startup
with second SQLite connection. Cancel integration test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 06:49:40 -05:00
whit3rabbit 9803668b7d feat(proxy): wire batch_engine into proxy crate
- Replace batch/mod.rs types with re-exports from anyllm_batch_engine
- Strip batch/db.rs to Anthropic->OpenAI ID mapping only (batch_file/batch_job owned by engine)
- Rewrite batch/routes.rs to use BatchEngine for upload, create, get, list, cancel
- Remove batch_file/batch_job table creation from admin/db.rs init_db
- Add batch_engine parameter to app_multi_with_shared (5th arg, Option<Arc<BatchEngine>>)
- Initialize BatchEngine in main.rs with its own SQLite connection (admin-enabled path)
- Update batch_api.rs tests to use make_test_batch_engine() helper
- Fix anthropic_batch.rs to call init_anthropic_batch_map_table instead of removed init_batch_tables
- Add cancel_queued_batch integration test
2026-03-31 06:48:16 -05:00
whit3rabbitandClaude Sonnet 4.6 2019f92824 fix(admin): enforce one-time CSRF tokens tracked server-side
GET /admin/csrf-token now stores the generated token in
SharedState::issued_csrf_tokens (DashMap). validate_csrf middleware
verifies the X-CSRF-Token header was server-issued and removes it on
first use, preventing replay of previously issued tokens across multiple
mutating requests. validate_csrf switched to from_fn_with_state to
receive SharedState. Adds test: post_with_unissued_csrf_returns_403.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 06:42:41 -05:00
whit3rabbitandClaude Sonnet 4.6 060c0e418f fix(mcp): validate server names to prevent tool routing ambiguity
MCP tool names use mcp_{server}_{tool}; underscores in server names make
parse_mcp_tool_name ambiguous. is_valid_mcp_server_name rejects names
containing underscores (allows alphanumerics + hyphens only).
register_server_blocking now returns Result<(), String> so callers handle
invalid names explicitly. Callers in main.rs and admin routes updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 06:36:41 -05:00
whit3rabbitandClaude Sonnet 4.6 babd0e6ff0 fix(tools): enforce allowed_dirs allowlist in ReadFileTool
Adds allowed_dirs config field to BuiltinToolConfig. ReadFileTool now
rejects reads outside the configured base directories after canonicalize(),
blocking both path traversal and symlink attacks. Logs a warning when
allowed_dirs is empty. Threads config through register_all so constructors
receive per-tool settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 06:33:47 -05:00
whit3rabbitandClaude Opus 4.6 cf417c9cfb fix(tools): share reqwest client and add SSRF validation for MCP URLs
McpServerManager now holds a shared reqwest::Client (built once in new()).
call_tool uses self.client instead of creating a new client per call.
discover_tools_impl extracted as a free fn; both the instance method
(discover_tools_with_client) and the static fallback delegate to it.

SSRF protection added at both registration points:
- admin add_mcp_server endpoint: validate_base_url() before calling discover
- main.rs startup: skip and log any MCP server URL that fails SSRF check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:47:56 -05:00
whit3rabbitandClaude Sonnet 4.6 a88a48b50e fix: wire tool engine and MCP manager into main.rs from config
ToolEngineState and McpServerManager were hardcoded to None. Now:
- SimpleParsed carries a ToolStartupConfig with the three tool sections
- LoadResult exposes that config to main.rs
- main.rs constructs ToolEngineState (registry, policy, loop config) when
  any tool section is present; MCP servers are discovered async at startup
  with a warning on failure (no panic)
- tool_engine and mcp_manager on SharedState are populated from the same
  Arc so both proxy handlers and admin API share the same instance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:35:44 -05:00
whit3rabbitandClaude Sonnet 4.6 3a62796a75 feat(tools): wire ToolEngineState into AppState with config-driven setup
Add ToolEngineState struct (registry, policy, loop_config, mcp_manager)
and tool_engine field to AppState. Update app_multi_with_shared signature
to accept the new parameter; all callers pass None until config-driven
wiring is implemented in a future task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:07:23 -05:00
whit3rabbitandClaude Sonnet 4.6 0253b47819 feat(admin): add MCP server management endpoints (list, add, remove)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:03:55 -05:00
whit3rabbitandClaude Opus 4.6 b6277a8b86 feat: operator observability dashboard with error classification
- Admin UI: operator view with request volume, token usage, latency,
  cost charts, failure table, and request timeline
- Backend: add error_kind() method and infer_error_kind() for stable
  error classification (rate_limit, timeout, backend_error, client_error)
- Admin DB: error_kind column in request_log, observability aggregate
  queries (bucketed timeseries, failure breakdown, timeline)
- Gemini: improved streaming translation, thinking block support,
  grounding metadata passthrough
- Request timeout: configurable REQUEST_TIMEOUT_SECS with streaming
  watchdog
- Model pricing: MODEL_PRICING_FILE for external pricing overrides
- Degradation header: ANYLLM_DEGRADATION_WARNINGS env var control

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:12:03 -05:00
whit3rabbit 20549b11b9 fix(security): emit error-level log when PROXY_OPEN_RELAY is active on non-loopback address 2026-03-30 17:06:51 -05:00
whit3rabbitandClaude Sonnet 4.6 d362c3d609 fix: interpret \n \t \\ escape sequences in double-quoted env file values
Add unescape_double_quoted helper; update parse_env_file to call it for
double-quoted values only. Single-quoted values remain literal (POSIX).
Includes three unit tests covering \n, \t, and single-quote no-op.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:55:03 -05:00
whit3rabbitandClaude Opus 4.6 5c73adfaea feat(admin): team-focused UI overhaul with key editing and audit log
Backend:
- PUT /admin/api/keys/{id}: update virtual key fields (role immutable),
  refreshes DashMap, emits key_updated audit entry
- update_virtual_key(): resets spend period when budget_duration changes
- query_audit_log(): action, target_type, since, until filter params
- query_request_log(): add until filter param; wire through RequestsQuery
- GET /admin/api/metrics: include streaming counters (started/completed/failed/disconnected)
- GET /admin/api/env: include RATE_LIMIT_FAIL_POLICY

UI:
- Dashboard: streaming metrics stat row
- Request Log: key filter dropdown, since/until date pickers, Key column
- Access Control tab: allowed_models tag input, edit modal, budget progress
  bars, key prefix link navigates to filtered request log
- Settings: override badges from overridden_keys, Security section
  (IP allowlist, rate limit fail policy)
- Audit tab: action/target/date filters, paginated table

Tests:
- 7 unit tests for update_virtual_key and query_audit_log filters
- 4 integration tests for PUT /admin/api/keys/{id} lifecycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 18:08:34 -05:00
whit3rabbit 52c6ffa3dd feat: compute requests_per_second from SQLite in admin metrics snapshot 2026-03-27 22:38:45 -05:00
whit3rabbitandClaude Sonnet 4.6 0408e669f3 feat: wire Langfuse named callback into CallbackConfig and startup
- Add NamedIntegration support to CallbackConfig (with_named constructor,
  named_count(), notify() dispatches to named integrations)
- Derive Clone on NamedIntegration (required since CallbackConfig derives Clone)
- Parse "langfuse" from litellm_settings.callbacks into langfuse_requested
  flag; filter it out of callback_urls
- Init LangfuseClient from env when langfuse_requested in LiteLLM config
- Env-var-only path in main.rs also activates Langfuse if keys are set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:31:53 -05:00
whit3rabbit 373d55d36e security: warn at startup when serving API keys over plaintext HTTP
Add warning logs when the proxy listener is bound to a non-loopback
address and either PROXY_API_KEYS is configured or virtual keys are
loaded from the database. Warns operators to place a TLS-terminating
reverse proxy in front of the service to protect credentials.

Does not block startup; purely informational.
2026-03-27 15:52:40 -05:00
whit3rabbitandClaude Sonnet 4.6 f3f5179a64 security: add rate limiting to admin API endpoints (10 RPM per IP)
Defense-in-depth against token brute-force on the admin API. Uses a
DashMap-based sliding window (60s) per client IP, applied as the
outermost middleware layer on protected admin routes. Admin server now
uses into_make_service_with_connect_info to expose client IP. Limit
is configurable at runtime via set_admin_rpm for test flexibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:45:37 -05:00
whit3rabbitandClaude Sonnet 4.6 4bf6b65bb8 security: configurable admin token path via ADMIN_TOKEN_PATH
Extract resolve_admin_token_path() function to read ADMIN_TOKEN_PATH
env var (falling back to .admin_token). Replaces the previous inline
ADMIN_TOKEN_FILE env var. Updates non-Unix warning to reference the
new env var name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:17:53 -05:00
whit3rabbitandClaude Sonnet 4.6 915a703a5a security: HMAC-SHA256 keyed hashing for virtual keys with dual-mode migration
New virtual keys are hashed with HMAC-SHA256 using a per-installation
secret (auto-generated and stored in SQLite settings table). Auth
middleware tries HMAC hash first, falls back to legacy SHA-256 for
pre-existing keys. This binds key hashes to the installation, so a
stolen database cannot be used to brute-force keys elsewhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:14:34 -05:00
whit3rabbitandClaude Opus 4.6 05461c8db0 feat: spend threshold alerts at 80%/95%/100% via webhooks
Add spend_threshold_level() to detect when a key crosses 80%, 95%, or
100% of its budget. Dedup via a global DashMap so each threshold fires
only once per budget period. After accumulate_spend(), read the updated
spend from SQLite and fire a spend_alert webhook via the existing
CallbackConfig infrastructure (new notify_json method).

Includes reset_alert_level() for budget period rollover and 4 unit tests
covering threshold boundaries, dedup behavior, and map reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:35:14 -05:00
whit3rabbitandClaude Opus 4.6 c84c2c0a68 chore: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:51:18 -05:00
whit3rabbitandClaude Opus 4.6 1b31fd4858 security: consolidate set_var calls to single pre-runtime block
Refactor env var mutation to eliminate scattered set_var calls:
- env_aliases: new compute_env_aliases() returns pairs without side effects
- litellm: parse_litellm_yaml returns master_key instead of calling set_var
- config/mod: MultiConfig::load() returns LoadResult with litellm_master_key
- main: all set_var calls grouped in clearly marked phases before any spawns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:49:40 -05:00
whit3rabbitandClaude Opus 4.6 ec945e3ce8 feat: configurable Redis rate limit failure policy (open/closed)
Add RATE_LIMIT_FAIL_POLICY env var (open/closed) so operators can choose
whether to allow or reject requests when Redis is unavailable. Defaults
to fail-open for backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:37:14 -05:00
whit3rabbitandClaude Sonnet 4.6 25279835a7 feat: add LiteLLM config.yaml compatibility and model-level routing
Accept LiteLLM config.yaml directly via PROXY_CONFIG=config.yaml. Parses
model_list with provider/model format, supports multiple deployments per
model name with round-robin + RPM-aware load balancing, cross-backend
dispatch, and os.environ/VAR env var syntax. Adds env var aliases for
LITELLM_MASTER_KEY, AZURE_API_KEY, AZURE_API_BASE, LITELLM_CONFIG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 05:39:51 -05:00
whit3rabbitandClaude Opus 4.6 d6b9d61071 feat: add caching, batch API, cost tracking, budget/RBAC, audio/image passthrough
LiteLLM parity features:
- Response caching (in-memory moka, optional Redis tier) with per-request TTL
- Batch API (file upload, job creation/listing via OpenAI delegation)
- Per-key budget enforcement (daily/monthly/lifetime) with lazy period reset
- RBAC (admin/developer key roles, developer keys blocked from /admin/)
- Audio transcription/speech and image generation passthrough
- Fallback chain config (YAML-based, 5xx/429 failover)
- Cost tracking foundation (model pricing DB, per-key spend queries)

Code quality cleanup (simplify pass):
- Extract try_cache_response helper (was copy-pasted 4x)
- Extract common_routes for batch/models (was duplicated across 3 HandlerMode arms)
- Deduplicate embeddings_passthrough to delegate to raw_passthrough
- Remove dead code: inject_cost_header, BudgetDuration::from_str_lossy, duplicate CacheConfig
- Collapse epoch_to_ymd wrapper into pub(crate) days_to_ymd

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:23:38 -05:00
whit3rabbitandClaude Opus 4.6 abb5b90e1d feat: add Bedrock backend, OpenTelemetry export, integration tests
- AWS Bedrock backend: SigV4 signing, InvokeModel + InvokeModelWithResponseStream
  with binary event stream decoding, passthrough handler for /v1/messages
- OpenTelemetry export: feature-gated (--features otel), OTLP/HTTP with
  reqwest transport, OtelGuard for graceful shutdown flush
- Chat completions integration tests: 6 tests covering non-streaming,
  error handling, degradation headers, system messages
- Updated COMPARISON_LITELLM.md to reflect all closed gaps
- Fixed Bedrock match arms across all handler files

549 tests passing, 0 failures, clippy clean.
Both `cargo build` and `cargo build --features otel` compile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:21:24 -05:00
whit3rabbitandClaude Opus 4.6 a4e655c8bb feat: LiteLLM gap fill - chat completions input, Azure backend, virtual keys, client SDK
Phase 1-8 implementation of the LiteLLM gap fill feature set:

- POST /v1/chat/completions: Accept OpenAI-format input, translate through
  Anthropic pipeline, return OpenAI-format responses (streaming + non-streaming)
- Reverse translation layer: openai_to_anthropic_request, anthropic_to_openai_response,
  ReverseStreamingTranslator (Anthropic SSE -> OpenAI ChatCompletionChunk)
- Azure OpenAI backend: BACKEND=azure with deployment-scoped URLs, api-key header,
  api-version query param (default 2024-10-21)
- Virtual key management: SQLite-backed CRUD via admin API (POST/GET/DELETE
  /admin/api/keys), DashMap in-memory cache, immediate revocation
- Per-key rate limiting: RPM sliding window enforcement in auth middleware,
  429 with retry-after header on limit exceeded
- Client library v0.2.0: ClientBuilder, ToolBuilder, ToolChoiceBuilder,
  typed streaming, rustdoc examples
- New dependencies: dashmap, aws-sigv4, aws-credential-types (prod);
  opentelemetry stack (feature-gated, optional)

534 tests passing, 0 failures, clippy clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:09:40 -05:00
whit3rabbitandClaude Sonnet 4.6 f1df50ff37 refactor: extract shared client crate and rename to anyllm_*
Introduce `anyllm_client` crate containing HTTP client construction,
SSRF-safe DNS resolver, retry/backoff logic, rate limit header parsing,
and SSE frame parsing. These were previously inlined in the proxy crate.

Rename crates from `anthropic_openai_proxy`/`anthropic_openai_translate`
to `anyllm_proxy`/`anyllm_translate` throughout.

proxy/backend: now re-exports retry, rate limit, and SSE symbols from
the client crate; `send_with_retry` and `build_http_client` are thin
adapters bridging BackendAuth/TlsConfig to the client crate's types.

streaming: remove duplicate `find_double_newline` and
`MAX_SSE_BUFFER_SIZE` definitions; import from `crate::backend` instead.

Fix missing `pub mod` declarations in translator and proxy that were
accidentally replaced by doc comments (streaming, usage_map, server,
redact).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 06:11:01 -05:00
whit3rabbitandClaude Opus 4.6 30611bff48 fix: security hardening, streaming correctness, and admin robustness
Security: hash API keys with SHA-256 for constant-time comparison (eliminates
length timing leak), require explicit PROXY_OPEN_RELAY for unauthenticated
access, gate /metrics behind auth, sanitize admin error responses, enforce
log_level allowlist on DB restore, validate GCP identifiers against URL
injection, add referrer-policy header to admin SPA.

Correctness: hold concurrency semaphore permit through entire stream lifetime
(not just until headers are sent), fix SSE parser to resume scanning near
chunk boundaries instead of re-scanning from start, mark padding tool call
slots as closed to prevent spurious ContentBlockStop events, mark responses
streaming translator as finished on error to prevent double closure events,
serialize concurrent config writes with a Mutex.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:14:37 -05:00
whit3rabbitandClaude Opus 4.6 f6c5aae22d fix: address security audit findings (timing, log escalation, TOCTOU, gitignore)
- Use constant-time comparison (subtle::ConstantTimeEq) for proxy API key
  validation, matching the pattern already used in admin auth
- Restrict admin config log_level to allowlist (error/warn/info/debug) to
  prevent trace-level logging that leaks secrets in HTTP headers
- Create admin token file with atomic 0o600 permissions via OpenOptionsExt
  to eliminate TOCTOU race where file is briefly world-readable
- Add .admin_token, *.db, and TLS key material to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:19:00 -05:00
whit3rabbitandClaude Opus 4.6 379052b79a fix: security hardening, correctness fixes, and extended thinking support
- Constant-time token comparison in admin auth (timing side-channel)
- Origin header parsing via URL to prevent bypass (e.g., 127.0.0.1.attacker.com)
- CSP headers and X-Frame-Options on admin SPA
- WebSocket origin check for cross-site WS hijacking prevention
- Switch admin DB mutex from tokio::Mutex to std::Mutex, use spawn_blocking
- Poison-recovery on std::sync locks (unwrap_or_else + into_inner)
- JSON builder for SSE error fallback to prevent injection
- UTF-8-safe secret redaction (char-aware slicing)
- Add RedactedThinking content block and SignatureDelta streaming types
- Map OpenAI refusal field to Anthropic text block (non-streaming + streaming)
- Map OpenAI cached_tokens to Anthropic cache_read_input_tokens
- Map HTTP 408 to Anthropic OverloadedError
- Warn on trace log level and log_bodies enable via admin API
- Update CLAUDE.md docs to match current state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:11:25 -05:00
whit3rabbitandClaude Opus 4.6 9b00345746 fix: harden proxy with security, correctness, and reliability improvements
Security: strip admin token from browser URL, add cross-origin rejection
middleware for admin API, skip tool calls with empty names instead of
substituting "unknown".

Correctness: use BytesMut for SSE buffering to prevent UTF-8 corruption
at TCP chunk boundaries, use saturating_sub for epoch arithmetic, handle
CRLF SSE frame delimiters.

Runtime: switch runtime_config to std::sync::RwLock (guard is !Send),
use block_in_place for SQLite IO, spawn_blocking for tokenization,
add tracing reload layer so admin log_level changes apply immediately.

Reliability: retry failed log buffer flushes with capped retry queue,
add MAX_SSE_BUFFER_SIZE guard in middleware handler, cap tool call and
part indices to prevent unbounded vec growth.

Observability: defer streaming request logging until stream completes
so entries capture actual status, latency, and token counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:22:42 -05:00
whit3rabbitandClaude Opus 4.6 6148b44e46 feat: add admin dashboard with request logging and observability
Localhost-only admin server (default port 3001) with:
- Web UI dashboard with live request feed via WebSocket
- SQLite-backed request log with pagination and filtering
- Hot-reload config (model mappings, log level, log bodies) persisted
  to SQLite across restarts
- Per-backend metrics with error rate tracking
- Token-based auth (random UUID printed to stderr, or set ADMIN_TOKEN)
- Automatic log retention purge (ADMIN_LOG_RETENTION_DAYS, default 7)

Supporting changes:
- Backend error types gain status_code() for request log entries
- Metrics gains Clone, Default, error_rate() for admin aggregation
- Routes track per-request context (RequestCtx) and log to admin
- Error responses sanitized: internal details logged server-side only,
  clients receive generic messages (prevents infrastructure leaks)
- SSE buffer guard (10 MB max) prevents unbounded memory from
  misbehaving backends
- New deps: rusqlite (bundled), toml, indexmap, bytes, axum ws feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:34:48 -05:00
whit3rabbitandClaude Opus 4.6 0a9ca1ae31 fix: simplify retry loop, sanitize request IDs, and apply rustfmt (Phase 22)
Collapse retry + final-attempt into single inclusive loop, drain response
body before retry to return connections to pool, and replace panic on
invalid x-request-id with UUID fallback. Update CLAUDE.md/TASKS.md for
Phase 22 status. Apply cargo fmt across touched files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:41:06 -05:00
whit3rabbitandClaude Opus 4.6 3343aac893 feat: add graceful shutdown with SIGINT/SIGTERM and in-flight draining (Phase 22)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:58:14 -05:00
whit3rabbitandClaude Opus 4.6 f2e3ed15f4 Initial commit: Anthropic-to-OpenAI API translation proxy
Rust workspace with two crates:
- translator: pure, IO-free mapping between Anthropic Messages API and OpenAI Chat Completions
- proxy: axum HTTP server with auth, streaming SSE, retry/backoff, concurrency limits

169 tests passing (unit, golden fixture, integration).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:25:43 -05:00