Skip to content

Commit 962ab5b

Browse files
blisspixelclaude
andcommitted
fix: comprehensive bug-hunt sweep (cost, async, storage, providers, MCP)
Multi-scout audit found ~75 issues across security, cost/budget, async/concurrency, storage durability, and provider correctness. This commit closes the critical + high tier and most mediums. Critical (cost/security) - MCP confirmation gate stripped `_approved` kwarg before dispatch; previously every approved deepr_research / deepr_agentic_research call raised TypeError and returned a generic 500 (v2.10.2 regression). - CostSafetyManager.ABSOLUTE_MAX_{PER_OPERATION,DAILY,MONTHLY} added — the constants mcp/server.py and cli/budget.py referenced did not exist, so every deepr_agentic_research silently swallowed AttributeError. - POST /api/jobs now enforces CostController.check_cost_limit before provider submission; services/research_api.py and services/batch_executor added cost-safety gates; worker/poller records cost on completion. - experts/chat.py _deep_research used $0.20 hardcoded estimate while o4-mini-deep-research is ~$2.00 — sessions blew daily budget 10x faster than tracked. Multi-round tool loops now re-check cost_session.can_proceed between rounds. _quick_lookup gets a pre-flight budget check. - experts/cost_safety.py: threading.Lock + reservation pattern stops N parallel council/task-planner callers from over-committing against the same daily cap. New check_and_reserve / refund_reservation / record_cost(reservation_id=...) settle paths. Critical (durability) - 17+ files migrated to deepr/utils/atomic_io.atomic_write_json (profile_store, beliefs, memory, traces, embeddings cache, dspy_pipeline, knowledge_consolidation, metacognition, temporal_knowledge, user_profile, confidence_calibration, lazy_graph_rag, observability/{metadata,costs}, storage/local, web budget limits). The mkstemp + Windows-retry pattern from commit 4715021 is now a shared helper. - cost_ledger.jsonl appends now flush + fsync; routing_log uses append_jsonl_durable so crash-loss can't truncate the last record. - queue.update_results writes the cost-ledger event BEFORE the SQLite commit so a crash between the two can't leave the queue showing "completed with cost" while the canonical ledger has no row. - SQLite queue runs in WAL mode; partial UNIQUE(provider_job_id) index prevents double-billing on submit-retry races. Critical (async) - experts/task_planner.py: parallel _run_step coroutines on the same ExpertChatSession now serialise through asyncio.Lock instead of racing on self.messages / cost_accumulated / research_count. - mcp/state/async_dispatcher.py: dependency wait moved OUTSIDE the concurrency semaphore so chains of length > max_concurrent can no longer deadlock on slot-exhaustion. Provider correctness - providers/anthropic_provider.py: per-turn usage now accumulated and stored in self._jobs; get_status returns the real ResearchResponse including cost. Previously every Anthropic call returned $0 and no report — the provider was completely invisible to the cost ledger. - providers/registry.py: get_token_pricing normalises dot/hyphen (Grok 4.20 ~80% undercharge fixed) + applies aliases + sorts partial-match candidates by length (Flash-Lite vs Flash overcharge). - providers/openai_provider.py: rate-limit fallback now uses dataclasses.replace instead of mutating caller's request; log a warning when response.model is missing rather than silently defaulting to o4-mini pricing for an o3-deep-research job. - providers/gemini_provider.py: token counts read from chunk.usage_metadata (prompt + candidates + thoughts) instead of len(text)//4. Poll error handler whitelists transient errors and promotes 401/403/404 to job failure so file_search stores don't leak. - providers/azure_provider.py: 3-retry on RateLimit/Connection/Timeout matching OpenAI provider; None-model defaulted before calculate_cost. - providers/grok_provider.py: multi-agent budget pre-flight bumped to 16K worst-case output tokens; submit_research no longer re-raises mid-call (matches Gemini contract — caller gets job_id back). - experts/chat.py _chat_token_cost honours OpenAI's 50% cached-token discount via prompt_tokens_details.cached_tokens. - routing/deprecation.py strips provider prefix from migrated successor so downstream provider clients consume plain "grok-4-3" not "xai/...". - routing/auto_mode.py _cheapest_available no longer hard-codes openai/gpt-4.1-mini as last resort; iterates usable providers or raises RuntimeError honestly when none are configured. Other security/hardening - mcp/transport/http.py wraps hmac.compare_digest in TypeError catch (the fix that landed in web/app.py + api/app.py in v2.10.2 was missed here). SSE subscriber lifecycle: replacing subscriber_id signals the old handler to exit, no more zombie streams. - mcp/transport/stdio.py read loop dispatches handler coroutines via asyncio.create_task so an in-flight deepr_research can't block cancellations or other tool calls. - mcp/client/circuit_breaker.py is_available HALF_OPEN check + claim now under threading.Lock. - mcp/security/tool_allowlist.py: 6 missing tools registered (deepr_get_task_progress, deepr_list_recoverable_tasks, deepr_resume_task, deepr_pause_task, deepr_list_skills, deepr_install_skill). - deploy/aws/src/worker/worker.py: JOBS_TABLE added to startup env validation; update_job_status uses ConditionExpression so a late completion/failed write can't overwrite a cancelled state. - experts/profile_store.py list_all logs corruption at ERROR (not WARNING) and exposes .errors attribute so admin UI can surface hidden experts. ExpertStore.save uses atomic write. - experts/beliefs.py _load catches JSONDecodeError + 50 MB size cap guards against poisoned files; starts fresh on corruption instead of crashing expert load. - web/app.py: /api/experts/chat clamps caller-supplied budget against daily cap; /api/experts/council adds 5/min limit + ABSOLUTE_MAX budget clamp; citation-validation cache fill is now serialised per-expert so concurrent uncached requests don't fan out paid LLM batches; _save_limits surfaces write failures. - api/app.py: submit_job cost guard narrows broad except (was silently bypassing the budget check on any exception); swagger docs no longer claim the API is unauthenticated. - experts/chat.py session_id sanitises caller-supplied expert.name and agent_identity.agent_id via [^\w\-] regex. - experts/council.py: full-budget upfront reservation against the global cost-safety manager prevents N-way fan-out over-commit beyond the daily cap. Tests + coverage - 14 new test files (~80 new test cases) covering atomic_io, cost-safety reservations + absolute ceilings, MCP _approved stripping, MCP HTTP bearer TypeError, dispatcher dependency deadlock fix, Grok dot/hyphen alias normalisation, OpenAI fallback request immutability, Anthropic usage accumulation, profile_store corruption surfacing, chat _chat_token_cost (including cache-discount), findings store, user profile tracker, information gain tracker. - pyproject.toml coverage fail_under raised from 60% to 75% (current coverage: 78.5%). Extended omit list to exclude integration-tested surfaces (WebSocket events, CLI display, Playwright browser, web scraper, optional DSPy integration). - All 4401 unit tests pass. ruff check + format clean on deepr/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e2dd9b2 commit 962ab5b

85 files changed

Lines changed: 2721 additions & 578 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

deepr/api/app.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,12 @@ def _check_auth():
115115
```
116116
117117
## Authentication
118-
Currently, the API does not require authentication. Rate limiting is based on client IP address.
118+
Bearer token via ``Authorization: Bearer <token>`` is required when the server is
119+
started with ``DEEPR_API_TOKEN`` set, which is the recommended configuration for
120+
any non-loopback bind. Local-development binds to 127.0.0.1 may run without a
121+
token; the server refuses to start on a non-loopback host without either
122+
``DEEPR_API_TOKEN`` or ``DEEPR_ALLOW_PUBLIC_BIND=1``. Rate limiting is applied
123+
per client IP on top of authentication.
119124
""",
120125
"version": "1.0.0",
121126
"contact": {"name": "Deepr Support", "url": "https://github.com/deepr-ai/deepr"},
@@ -621,8 +626,18 @@ def submit_job():
621626
},
622627
}
623628
), 429
624-
except Exception as _e: # pragma: no cover - defensive; cost paths are well-tested
625-
logger.warning("Cost guard skipped due to internal error: %s", _e)
629+
except ImportError as _e:
630+
# CostController/Estimator missing is a deployment problem, not a
631+
# situation we should silently fall through. Fail-closed so the
632+
# cost-gate cannot be bypassed by a broken dependency.
633+
logger.error("Cost guard unavailable (ImportError): %s", _e)
634+
return jsonify({"error": "Cost controller unavailable"}), 503
635+
except (ValueError, TypeError) as _e:
636+
# Estimator-input problems are the caller's; everything else
637+
# should propagate and surface as a 500 instead of bypassing the
638+
# gate the way the previous broad except did.
639+
logger.warning("Cost guard input rejected: %s", _e)
640+
return jsonify({"error": f"Invalid cost-guard input: {_e}"}), 400
626641

627642
# Create job
628643
job_id = str(uuid.uuid4())

deepr/experts/beliefs.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@
2121
"""
2222

2323
import json
24+
import logging
2425
import math
2526
from dataclasses import dataclass, field
2627
from datetime import datetime, timezone
2728
from enum import Enum
2829
from pathlib import Path
2930
from typing import TYPE_CHECKING, Any, Optional
3031

32+
logger = logging.getLogger(__name__)
33+
3134
if TYPE_CHECKING:
3235
from deepr.core.contracts import Claim
3336

@@ -629,16 +632,32 @@ def _save(self):
629632
],
630633
}
631634

632-
with open(self.storage_path, "w", encoding="utf-8") as f:
633-
json.dump(data, f, indent=2)
635+
from deepr.utils.atomic_io import atomic_write_json
636+
637+
atomic_write_json(self.storage_path, data)
634638

635639
def _load(self):
636-
"""Load beliefs from disk."""
640+
"""Load beliefs from disk.
641+
642+
Catches corrupt/oversized files and starts fresh rather than
643+
crashing the expert load entirely. A 50 MB ceiling guards
644+
against poisoned belief files from corpus imports.
645+
"""
637646
if not self.storage_path.exists():
638647
return
639648

640-
with open(self.storage_path, encoding="utf-8") as f:
641-
data = json.load(f)
649+
try:
650+
if self.storage_path.stat().st_size > 50 * 1024 * 1024:
651+
logger.error(
652+
"Belief store at %s exceeds 50 MB; refusing to load. Inspect and reduce manually.",
653+
self.storage_path,
654+
)
655+
return
656+
with open(self.storage_path, encoding="utf-8") as f:
657+
data = json.load(f)
658+
except (json.JSONDecodeError, OSError) as exc:
659+
logger.error("Failed to load beliefs from %s: %s. Starting fresh.", self.storage_path, exc)
660+
return
642661

643662
self.beliefs = {bid: Belief.from_dict(bdata) for bid, bdata in data.get("beliefs", {}).items()}
644663

@@ -891,8 +910,9 @@ def _save(self):
891910
"contributors": {bid: list(experts) for bid, experts in self.contributors.items()},
892911
}
893912

894-
with open(self.storage_path, "w", encoding="utf-8") as f:
895-
json.dump(data, f, indent=2)
913+
from deepr.utils.atomic_io import atomic_write_json
914+
915+
atomic_write_json(self.storage_path, data)
896916

897917
def _load(self):
898918
"""Load shared beliefs from disk."""

deepr/experts/chat.py

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@
4040
def _chat_token_cost(usage: Any, model_name: str) -> float:
4141
"""Compute chat-completion cost from token usage using the model registry.
4242
43-
Replaces the previous hard-coded GPT-5 rates: when the chat session
44-
routes to gpt-5.2 the registry rates ($1.75/$14 per 1M) are applied
45-
instead of the GPT-5 rates ($1.25/$10 per 1M), so cost_accumulated and
46-
budget_remaining no longer under-count gpt-5.2 spend by ~28.6%.
43+
Uses ``get_token_pricing`` so any model in the registry (gpt-5.2 at
44+
$1.75/$14, etc.) is priced correctly. When ``usage`` exposes
45+
``prompt_tokens_details.cached_tokens`` (OpenAI caching), the cached
46+
portion is billed at 50% — without this discount users hit their
47+
session budget earlier than necessary on cache-hit-heavy workloads.
4748
"""
4849
if not usage:
4950
return 0.0
@@ -58,7 +59,16 @@ def _chat_token_cost(usage: Any, model_name: str) -> float:
5859
output_price = _DEFAULT_CHAT_OUTPUT_PRICE_PER_1M
5960
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
6061
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
61-
return (prompt_tokens / 1_000_000) * input_price + (completion_tokens / 1_000_000) * output_price
62+
63+
cached_tokens = 0
64+
details = getattr(usage, "prompt_tokens_details", None)
65+
if details is not None:
66+
cached_tokens = getattr(details, "cached_tokens", 0) or 0
67+
uncached_input = max(prompt_tokens - cached_tokens, 0)
68+
69+
input_cost = (uncached_input / 1_000_000) * input_price + (cached_tokens / 1_000_000) * input_price * 0.5
70+
output_cost = (completion_tokens / 1_000_000) * output_price
71+
return input_cost + output_cost
6272

6373

6474
class ExpertChatSession:
@@ -125,10 +135,19 @@ def __init__(
125135
from deepr.experts.cost_safety import get_cost_safety_manager
126136

127137
self.cost_safety = get_cost_safety_manager()
138+
# Sanitize identifiers feeding into session_id — agent_identity
139+
# is caller-supplied (downstream MCP / A2A may forward arbitrary
140+
# strings) and the session_id ends up in cost-safety keys and
141+
# potentially thought-stream log filenames.
142+
import re as _re
143+
144+
_safe_re = _re.compile(r"[^\w\-]+")
145+
_expert_part = _safe_re.sub("_", str(expert.name))[:64]
128146
if self.agent_identity:
129-
self.session_id = f"chat_{expert.name}_{self.agent_identity.agent_id}"
147+
_agent_part = _safe_re.sub("_", str(self.agent_identity.agent_id))[:64]
148+
self.session_id = f"chat_{_expert_part}_{_agent_part}"
130149
else:
131-
self.session_id = f"chat_{expert.name}_{uuid.uuid4().hex[:8]}"
150+
self.session_id = f"chat_{_expert_part}_{uuid.uuid4().hex[:8]}"
132151
self.cost_session = self.cost_safety.create_session(
133152
session_id=self.session_id, session_type="chat", budget_limit=self.budget
134153
)
@@ -610,6 +629,21 @@ async def _quick_lookup(self, query: str) -> dict:
610629
Returns:
611630
Dict with answer and sources
612631
"""
632+
# Pre-flight budget check. The previous implementation called the
633+
# model first and accounted afterwards; that can blow past a tight
634+
# session budget on a single call. Estimate via worst-case upper
635+
# bound (4K input + 2K output at gpt-5.2 rates ≈ $0.035) and let
636+
# the cost-safety layer veto if there's no room.
637+
estimated_cost = 0.05
638+
allowed, reason, _ = self.cost_safety.check_operation(
639+
session_id=self.session_id,
640+
operation_type="quick_lookup",
641+
estimated_cost=estimated_cost,
642+
require_confirmation=False,
643+
)
644+
if not allowed:
645+
return {"error": f"Quick lookup blocked: {reason}", "mode": "quick_lookup_gpt52", "status": "blocked"}
646+
613647
try:
614648
# Use GPT-5.2 with low reasoning effort for knowledge lookups
615649
response = await self.client.chat.completions.create(
@@ -626,14 +660,19 @@ async def _quick_lookup(self, query: str) -> dict:
626660

627661
answer = response.choices[0].message.content or ""
628662

629-
# Track cost (GPT-5.2: $1.75 input, $14 output per 1M tokens)
630-
if response.usage:
631-
input_cost = (response.usage.prompt_tokens / 1_000_000) * 1.75
632-
output_cost = (response.usage.completion_tokens / 1_000_000) * 14.00
633-
cost = input_cost + output_cost
634-
self.cost_accumulated += cost
635-
else:
636-
cost = 0.01 # Estimate ~5-10 cents for typical query
663+
# Token-priced via the registry so future rate changes for
664+
# gpt-5.2 don't silently drift from the budget bookkeeping.
665+
cost = _chat_token_cost(response.usage, "gpt-5.2") if response.usage else 0.01
666+
self.cost_accumulated += cost
667+
self.cost_safety.record_cost(
668+
session_id=self.session_id,
669+
operation_type="quick_lookup",
670+
actual_cost=cost,
671+
provider="openai",
672+
model="gpt-5.2",
673+
tokens_input=getattr(response.usage, "prompt_tokens", 0) if response.usage else 0,
674+
tokens_output=getattr(response.usage, "completion_tokens", 0) if response.usage else 0,
675+
)
637676

638677
return {"answer": answer, "mode": "quick_lookup_gpt52", "cost": cost}
639678
except Exception as e:
@@ -694,8 +733,14 @@ async def _standard_research(self, query: str) -> dict:
694733
)
695734
chat.append(user(query))
696735

697-
# Get response with automatic agentic search
698-
response = chat.sample()
736+
# xAI SDK's chat.sample() is a synchronous network call. Running
737+
# it directly here would freeze the event loop for 5-15s per
738+
# call, stalling every concurrent chat session, WebSocket emit,
739+
# and MCP request handler that shares this loop. Push it to a
740+
# worker thread so async behaviour holds.
741+
import asyncio as _asyncio_local
742+
743+
response = await _asyncio_local.to_thread(chat.sample)
699744

700745
# Extract answer and citations
701746
answer = response.content
@@ -751,11 +796,7 @@ async def _standard_research(self, query: str) -> dict:
751796

752797
answer = f"{response.choices[0].message.content or ''}\n\n[Note: Grok web search unavailable, using GPT-5.2 knowledge instead]"
753798

754-
cost = 0.01
755-
if response.usage:
756-
input_cost = (response.usage.prompt_tokens / 1_000_000) * 1.75
757-
output_cost = (response.usage.completion_tokens / 1_000_000) * 14.00
758-
cost = input_cost + output_cost
799+
cost = _chat_token_cost(response.usage, "gpt-5.2") if response.usage else 0.01
759800
self.cost_accumulated += cost
760801

761802
# Record fallback cost
@@ -784,7 +825,16 @@ async def _deep_research(self, query: str) -> dict:
784825
Returns:
785826
Dict with job_id and estimated_cost
786827
"""
787-
estimated_cost = 0.20 # Average estimate
828+
# Use the registry estimate as the budget reservation. The previous
829+
# hard-coded $0.20 was ~10x lower than the registry cost ($2.00)
830+
# for o4-mini-deep-research, so the session/daily budgets were
831+
# silently exhausted ten times faster than tracked.
832+
from deepr.providers.registry import get_cost_estimate as _get_cost_estimate
833+
834+
try:
835+
estimated_cost = float(_get_cost_estimate("o4-mini-deep-research"))
836+
except Exception:
837+
estimated_cost = 2.00 # registry default for o4-mini-deep-research
788838

789839
# Check cost safety before proceeding
790840
allowed, reason, _needs_confirm = self.cost_safety.check_operation(
@@ -1293,10 +1343,35 @@ def report_status(status: str):
12931343

12941344
max_rounds = 5 # Prevent infinite loops
12951345
round_count = 0
1346+
# Worst-case per-round estimate used for the mid-loop budget
1347+
# guard. Cheaper models will under-use this, but a tool round
1348+
# that runs a multi-thousand-token reasoning call shouldn't
1349+
# silently blow past the session budget between rounds.
1350+
_per_round_estimate = max(
1351+
_chat_token_cost(getattr(first_response, "usage", None), selected_model.model) * 1.5, 0.05
1352+
)
12961353

12971354
while current_message.tool_calls and round_count < max_rounds:
12981355
round_count += 1
12991356

1357+
# Re-check the session budget between rounds. The previous
1358+
# implementation only checked once at the start of the
1359+
# request, so a 5-round loop on a frontier model could
1360+
# blow well past `self.budget` before any guard tripped.
1361+
_can_continue, _round_reason = self.cost_session.can_proceed(_per_round_estimate)
1362+
if not _can_continue:
1363+
logger.warning(
1364+
"Tool loop aborted at round %d: %s (accumulated $%.4f, budget $%.4f)",
1365+
round_count,
1366+
_round_reason,
1367+
self.cost_accumulated,
1368+
self.budget,
1369+
)
1370+
current_message.content = (
1371+
current_message.content or ""
1372+
) + f"\n\n[Tool loop stopped after {round_count - 1} rounds: {_round_reason}]"
1373+
break
1374+
13001375
# Process each tool call
13011376
tool_messages = []
13021377

@@ -1881,9 +1956,25 @@ def report_status(status: str):
18811956
conversation_messages = [{"role": "system", "content": self.get_system_message()}, *self.messages]
18821957
max_rounds = 5
18831958
round_count = 0
1959+
_per_round_estimate = max(
1960+
_chat_token_cost(getattr(first_response, "usage", None), selected_model.model) * 1.5, 0.05
1961+
)
18841962

18851963
while current_message.tool_calls and round_count < max_rounds:
18861964
round_count += 1
1965+
# Mirror the non-streaming branch: re-check budget between
1966+
# rounds so the tool loop can't run away on its own.
1967+
_can_continue, _round_reason = self.cost_session.can_proceed(_per_round_estimate)
1968+
if not _can_continue:
1969+
logger.warning(
1970+
"Tool loop (streaming) aborted at round %d: %s",
1971+
round_count,
1972+
_round_reason,
1973+
)
1974+
current_message.content = (
1975+
current_message.content or ""
1976+
) + f"\n\n[Tool loop stopped after {round_count - 1} rounds: {_round_reason}]"
1977+
break
18871978
tool_messages = []
18881979

18891980
for tool_call in current_message.tool_calls:

deepr/experts/confidence_calibration.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,9 @@ def save(self, path: Path):
366366
}
367367

368368
path.parent.mkdir(parents=True, exist_ok=True)
369-
with open(path, "w", encoding="utf-8") as f:
370-
json.dump(data, f, indent=2)
369+
from deepr.utils.atomic_io import atomic_write_json
370+
371+
atomic_write_json(path, data)
371372

372373
@classmethod
373374
def load(cls, path: Path) -> "ConfidenceCalibrator":

0 commit comments

Comments
 (0)