claude-smart 0.2.46 → 0.2.48
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +19 -11
- package/bin/claude-smart.js +290 -68
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/README.md +11 -10
- package/plugin/dashboard/app/layout.tsx +20 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
- package/plugin/dashboard/package-lock.json +61 -390
- package/plugin/dashboard/package.json +2 -2
- package/plugin/opencode/dist/server.mjs +76 -2
- package/plugin/opencode/server.mts +79 -2
- package/plugin/pyproject.toml +6 -2
- package/plugin/scripts/smart-install.sh +7 -1
- package/plugin/src/claude_smart/cli.py +210 -22
- package/plugin/src/claude_smart/context_format.py +9 -9
- package/plugin/src/claude_smart/cs_cite.py +66 -19
- package/plugin/uv.lock +5 -5
- package/plugin/vendor/reflexio/.env.example +7 -0
- package/plugin/vendor/reflexio/README.md +3 -3
- package/plugin/vendor/reflexio/pyproject.toml +2 -1
- package/plugin/vendor/reflexio/reflexio/README.md +11 -6
- package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
- package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
- package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
- package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
- package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
- package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
- package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
- package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
- package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
- package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
- package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
- package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
- package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
- package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
- package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
- package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
- package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
- package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Embedding concern for ``LiteLLMClient`` — mixin + token-budget helpers (Tier-2.5).
|
|
2
|
+
|
|
3
|
+
``EmbeddingMixin`` holds the three ``self``-bound embedding methods
|
|
4
|
+
(``_resolve_default_embedding_model``, ``get_embedding``, ``get_embeddings``); the
|
|
5
|
+
four stateless token-budget helpers (``_get_embedding_limit``,
|
|
6
|
+
``_get_embedding_encoding``, ``_reject_cloud_mode``, ``_truncate_for_embedding``),
|
|
7
|
+
the module-level ``_TRUNCATION_WARNED_MODELS`` set and the budget constants live
|
|
8
|
+
alongside them at module scope.
|
|
9
|
+
|
|
10
|
+
SINK-1 (patch-where-used): the provider/router names this module references
|
|
11
|
+
(``resolve_model_name``, ``get_service_embeddings``, ``should_use_embedding_service``,
|
|
12
|
+
``NomicEmbedder``, ``LocalEmbedder``, ``is_chromadb_importable``) are imported HERE,
|
|
13
|
+
so tests patch them at ``_litellm_embedding.<name>`` — patching the old facade
|
|
14
|
+
namespace would no-op and a no-op would hit the real embedder/137M model/network
|
|
15
|
+
(the P0). ``litellm.get_model_info``/``litellm.embedding`` are called via the shared
|
|
16
|
+
``litellm`` module attr so the global mock and ``get_model_info`` patch still apply.
|
|
17
|
+
|
|
18
|
+
SINK-2 (identity): ``_TRUNCATION_WARNED_MODELS`` and the three test-imported budget
|
|
19
|
+
functions are re-exported by import binding from the facade — the facade set IS this
|
|
20
|
+
set (the test autouse ``.clear()`` fixture mutates it through the facade).
|
|
21
|
+
|
|
22
|
+
Bodies moved VERBATIM; only the ``self``-typing (per-mixin TYPE_CHECKING stubs of
|
|
23
|
+
foreign members, Tier-1b idiom) is added.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from functools import lru_cache
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
import litellm
|
|
31
|
+
import tiktoken
|
|
32
|
+
|
|
33
|
+
from reflexio.server.llm._litellm_types import LiteLLMClientError
|
|
34
|
+
from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
|
|
35
|
+
from reflexio.server.llm.providers.embedding_service_provider import (
|
|
36
|
+
EmbeddingUnavailableError,
|
|
37
|
+
embedding_provider_mode,
|
|
38
|
+
get_service_embeddings,
|
|
39
|
+
should_use_embedding_service,
|
|
40
|
+
)
|
|
41
|
+
from reflexio.server.llm.providers.local_embedding_provider import (
|
|
42
|
+
LocalEmbedder,
|
|
43
|
+
)
|
|
44
|
+
from reflexio.server.llm.providers.local_embedding_provider import (
|
|
45
|
+
is_chromadb_importable as _is_chromadb_importable,
|
|
46
|
+
)
|
|
47
|
+
from reflexio.server.llm.providers.nomic_embedding_provider import (
|
|
48
|
+
NomicEmbedder,
|
|
49
|
+
)
|
|
50
|
+
from reflexio.server.llm.providers.nomic_embedding_provider import (
|
|
51
|
+
is_nomic_model as _is_nomic_model,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from reflexio.server.llm._litellm_types import LiteLLMConfig
|
|
56
|
+
|
|
57
|
+
_LOGGER = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
# OpenAI's documented max input length for text-embedding-3-* and ada-002 is
|
|
60
|
+
# 8191 tokens. Used as the fallback limit only when a model's name looks
|
|
61
|
+
# OpenAI-family but litellm's registry has no entry for it.
|
|
62
|
+
_OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS = 8191
|
|
63
|
+
|
|
64
|
+
# Models whose truncation warning has already been emitted this process. Keeps
|
|
65
|
+
# batch backfills of millions of long docs from flooding logs — the first hit
|
|
66
|
+
# per model goes to WARNING, everything after to DEBUG.
|
|
67
|
+
_TRUNCATION_WARNED_MODELS: set[str] = set()
|
|
68
|
+
|
|
69
|
+
# Model-name prefixes that route through OpenAI's embedding API (and therefore
|
|
70
|
+
# share the 8191-token cap). Anything that does not start with one of these is
|
|
71
|
+
# treated as "unknown provider" when litellm has no registry entry.
|
|
72
|
+
_OPENAI_EMBEDDING_FAMILY_PREFIXES = ("text-embedding-", "openai/", "azure/")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@lru_cache(maxsize=32)
|
|
76
|
+
def _get_embedding_limit(model: str) -> int | None:
|
|
77
|
+
"""
|
|
78
|
+
Resolve the maximum input token count for an embedding model.
|
|
79
|
+
|
|
80
|
+
Consults ``litellm.get_model_info`` first so provider-specific caps are
|
|
81
|
+
respected (OpenAI ~8191, Cohere 512, Voyage 32000, etc.). When litellm has
|
|
82
|
+
no entry for the model, falls back to the OpenAI 8191 cap only when the
|
|
83
|
+
model name looks OpenAI-family; otherwise returns ``None`` to disable
|
|
84
|
+
truncation for unknown providers (safer than over-truncating their input).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
model (str): Embedding model name (e.g. 'text-embedding-3-small',
|
|
88
|
+
'cohere/embed-english-v3.0').
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
int | None: Maximum input tokens, or ``None`` when the limit is unknown
|
|
92
|
+
and no safe fallback applies.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
info = litellm.get_model_info(model)
|
|
96
|
+
except Exception:
|
|
97
|
+
info = None
|
|
98
|
+
if info and info.get("mode") == "embedding":
|
|
99
|
+
max_tokens = info.get("max_input_tokens")
|
|
100
|
+
if isinstance(max_tokens, int) and max_tokens > 0:
|
|
101
|
+
return max_tokens
|
|
102
|
+
if model.startswith(_OPENAI_EMBEDDING_FAMILY_PREFIXES):
|
|
103
|
+
return _OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@lru_cache(maxsize=16)
|
|
108
|
+
def _get_embedding_encoding(model: str) -> tiktoken.Encoding:
|
|
109
|
+
"""
|
|
110
|
+
Return the tiktoken encoding for an embedding model, falling back to cl100k_base.
|
|
111
|
+
|
|
112
|
+
For non-OpenAI providers tiktoken does not know the real tokenizer, so the
|
|
113
|
+
cl100k_base fallback is an approximate proxy for token counting. That is
|
|
114
|
+
acceptable here because we truncate toward the provider's cap with the
|
|
115
|
+
proxy, which tends to over-truncate by a small fraction rather than under-
|
|
116
|
+
truncate and cause upstream 400s.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
model (str): Embedding model name (e.g. 'text-embedding-3-small').
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
tiktoken.Encoding: Encoder to use for token counting and truncation.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
return tiktoken.encoding_for_model(model)
|
|
126
|
+
except KeyError:
|
|
127
|
+
return tiktoken.get_encoding("cl100k_base")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _reject_cloud_mode(embedding_model: str, mode: str) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Raise when a local-only embedding model is configured for cloud mode.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
embedding_model (str): The resolved embedding model name.
|
|
136
|
+
mode (str): The resolved embedding provider mode.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
EmbeddingUnavailableError: If ``mode`` is ``"cloud"``.
|
|
140
|
+
"""
|
|
141
|
+
if mode == "cloud":
|
|
142
|
+
raise EmbeddingUnavailableError(
|
|
143
|
+
f"Local embedding model {embedding_model!r} cannot use cloud mode"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _truncate_for_embedding(
|
|
148
|
+
text: str, model: str, max_tokens: int | None = None
|
|
149
|
+
) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Truncate a string so its token count fits within an embedding model's input limit.
|
|
152
|
+
|
|
153
|
+
The token budget is auto-resolved from ``_get_embedding_limit`` by default.
|
|
154
|
+
When the model has no known limit (unknown provider not in litellm's
|
|
155
|
+
registry and not OpenAI-family), returns the text unchanged — over-
|
|
156
|
+
truncating an unknown provider's input is worse than passing it through
|
|
157
|
+
and letting the provider's own error surface.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
text (str): Raw input text.
|
|
161
|
+
model (str): Embedding model name, used to pick the tokenizer and the
|
|
162
|
+
per-provider token cap.
|
|
163
|
+
max_tokens (int | None): Override for the resolved budget. Primarily
|
|
164
|
+
used by tests to exercise the truncation path on short strings;
|
|
165
|
+
leave as ``None`` in production callers.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
str: Original text if it already fits (or the model has no known
|
|
169
|
+
limit), otherwise a token-bounded prefix.
|
|
170
|
+
"""
|
|
171
|
+
if not text:
|
|
172
|
+
return text
|
|
173
|
+
if max_tokens is None:
|
|
174
|
+
max_tokens = _get_embedding_limit(model)
|
|
175
|
+
if max_tokens is None:
|
|
176
|
+
return text
|
|
177
|
+
encoding = _get_embedding_encoding(model)
|
|
178
|
+
tokens = encoding.encode(text, disallowed_special=())
|
|
179
|
+
if len(tokens) <= max_tokens:
|
|
180
|
+
return text
|
|
181
|
+
if model in _TRUNCATION_WARNED_MODELS:
|
|
182
|
+
_LOGGER.debug(
|
|
183
|
+
"Truncating embedding input from %d to %d tokens for model %s",
|
|
184
|
+
len(tokens),
|
|
185
|
+
max_tokens,
|
|
186
|
+
model,
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
_TRUNCATION_WARNED_MODELS.add(model)
|
|
190
|
+
_LOGGER.warning(
|
|
191
|
+
"Truncating embedding input from %d to %d tokens for model %s "
|
|
192
|
+
"(further occurrences will be logged at DEBUG)",
|
|
193
|
+
len(tokens),
|
|
194
|
+
max_tokens,
|
|
195
|
+
model,
|
|
196
|
+
)
|
|
197
|
+
return encoding.decode(tokens[:max_tokens])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class EmbeddingMixin:
|
|
201
|
+
"""Embedding dispatch (service / Nomic / local ONNX / litellm) for ``LiteLLMClient``.
|
|
202
|
+
|
|
203
|
+
Mixed into ``LiteLLMClient``; the ``self`` members these methods read are
|
|
204
|
+
owned by the client-core ``__init__`` on the facade. The annotation-only
|
|
205
|
+
stubs below (Tier-1b idiom) give pyright the foreign-member types without
|
|
206
|
+
introducing shared class-level mutable state — NEVER assign here.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
# Base-owned attributes these methods read (init'd in the facade ``__init__``).
|
|
210
|
+
config: "LiteLLMConfig"
|
|
211
|
+
# Lazy per-instance cache; ``_resolve_default_embedding_model`` populates it,
|
|
212
|
+
# ``update_config`` invalidates it. Annotation-only (no class default).
|
|
213
|
+
_default_embedding_model: str | None
|
|
214
|
+
|
|
215
|
+
if TYPE_CHECKING:
|
|
216
|
+
# Client-core credential resolver (stays on the facade per the split).
|
|
217
|
+
# Declared type-only so pyright can resolve ``self._resolve_api_key(...)``.
|
|
218
|
+
def _resolve_api_key(
|
|
219
|
+
self, model: str | None = ..., for_embedding: bool = ...
|
|
220
|
+
) -> tuple[str | None, str | None, str | None]: ...
|
|
221
|
+
|
|
222
|
+
def _resolve_default_embedding_model(self) -> str:
|
|
223
|
+
"""
|
|
224
|
+
Resolve the embedding model to use when callers do not specify one.
|
|
225
|
+
|
|
226
|
+
Routes through the same auto-detection chain as the rest of reflexio
|
|
227
|
+
(``resolve_model_name`` for ``ModelRole.EMBEDDING``) so a session that
|
|
228
|
+
has the local ONNX embedder enabled — or any non-OpenAI provider —
|
|
229
|
+
does not silently fall back to ``text-embedding-3-small`` and produce
|
|
230
|
+
OpenAI 401s. Higher-precedence org config and site-var overrides are
|
|
231
|
+
the caller's responsibility to resolve and pass via ``model=``; this
|
|
232
|
+
helper handles only the auto-detect tier.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
str: The auto-detected embedding model name (cached after first call).
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
RuntimeError: Propagated from ``resolve_model_name`` when no
|
|
239
|
+
embedding-capable provider is available.
|
|
240
|
+
"""
|
|
241
|
+
if self._default_embedding_model is None:
|
|
242
|
+
self._default_embedding_model = resolve_model_name(
|
|
243
|
+
ModelRole.EMBEDDING,
|
|
244
|
+
api_key_config=self.config.api_key_config,
|
|
245
|
+
)
|
|
246
|
+
return self._default_embedding_model
|
|
247
|
+
|
|
248
|
+
def get_embedding(
|
|
249
|
+
self, text: str, model: str | None = None, dimensions: int | None = None
|
|
250
|
+
) -> list[float]:
|
|
251
|
+
"""
|
|
252
|
+
Get embedding vector for the given text.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
text: The text to get embedding for.
|
|
256
|
+
model: Optional embedding model. When omitted, the model is
|
|
257
|
+
auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
|
|
258
|
+
so callers inherit the local-embedder gate and any non-OpenAI
|
|
259
|
+
provider configured for this client.
|
|
260
|
+
dimensions: Optional number of dimensions for the embedding vector.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of floats representing the embedding vector.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
LiteLLMClientError: If embedding generation fails.
|
|
267
|
+
"""
|
|
268
|
+
embedding_model = model or self._resolve_default_embedding_model()
|
|
269
|
+
mode = embedding_provider_mode(embedding_model)
|
|
270
|
+
if mode == "off":
|
|
271
|
+
raise EmbeddingUnavailableError("Embedding provider is disabled")
|
|
272
|
+
if should_use_embedding_service(embedding_model):
|
|
273
|
+
return get_service_embeddings(
|
|
274
|
+
[text], model=embedding_model, dimensions=dimensions
|
|
275
|
+
)[0]
|
|
276
|
+
|
|
277
|
+
# local/nomic-embed-* must stay on the Nomic provider (137M params,
|
|
278
|
+
# 768d Matryoshka-truncated to 512). Falling through to MiniLM would
|
|
279
|
+
# mix embedding models inside existing vector stores.
|
|
280
|
+
if _is_nomic_model(embedding_model):
|
|
281
|
+
_reject_cloud_mode(embedding_model, mode)
|
|
282
|
+
try:
|
|
283
|
+
return NomicEmbedder.get().embed([text])[0]
|
|
284
|
+
except Exception as e:
|
|
285
|
+
raise LiteLLMClientError(
|
|
286
|
+
f"Nomic embedding generation failed: {str(e)}"
|
|
287
|
+
) from e
|
|
288
|
+
|
|
289
|
+
# local/* models route through the in-process ONNX embedder — no
|
|
290
|
+
# network call, no litellm API, no tiktoken truncation (the embedder
|
|
291
|
+
# applies its own token cap). The dispatch is gated solely on
|
|
292
|
+
# ``chromadb`` being importable; the env-var opt-in (claude-smart's
|
|
293
|
+
# ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
|
|
294
|
+
# auto-detection layer (see ``model_defaults._auto_detect_model``).
|
|
295
|
+
if embedding_model.startswith("local/"):
|
|
296
|
+
_reject_cloud_mode(embedding_model, mode)
|
|
297
|
+
if not _is_chromadb_importable():
|
|
298
|
+
raise LiteLLMClientError(
|
|
299
|
+
f"Embedding model {embedding_model!r} requires chromadb. "
|
|
300
|
+
"Run `pip install chromadb`."
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
return LocalEmbedder.get().embed([text])[0]
|
|
304
|
+
except Exception as e:
|
|
305
|
+
raise LiteLLMClientError(
|
|
306
|
+
f"Local embedding generation failed: {str(e)}"
|
|
307
|
+
) from e
|
|
308
|
+
|
|
309
|
+
text = _truncate_for_embedding(text, embedding_model)
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
params = {"model": embedding_model, "input": [text]}
|
|
313
|
+
if dimensions:
|
|
314
|
+
params["dimensions"] = dimensions
|
|
315
|
+
|
|
316
|
+
# Resolve and add API key configuration if provided (overrides env vars)
|
|
317
|
+
api_key, api_base, api_version = self._resolve_api_key(
|
|
318
|
+
embedding_model, for_embedding=True
|
|
319
|
+
)
|
|
320
|
+
if api_key:
|
|
321
|
+
params["api_key"] = api_key
|
|
322
|
+
if api_base:
|
|
323
|
+
params["api_base"] = api_base
|
|
324
|
+
if api_version:
|
|
325
|
+
params["api_version"] = api_version
|
|
326
|
+
|
|
327
|
+
response = litellm.embedding(
|
|
328
|
+
**params,
|
|
329
|
+
timeout=self.config.timeout,
|
|
330
|
+
num_retries=self.config.max_retries,
|
|
331
|
+
)
|
|
332
|
+
return response.data[0]["embedding"]
|
|
333
|
+
except Exception as e:
|
|
334
|
+
raise LiteLLMClientError(f"Embedding generation failed: {str(e)}") from e
|
|
335
|
+
|
|
336
|
+
def get_embeddings(
|
|
337
|
+
self,
|
|
338
|
+
texts: list[str],
|
|
339
|
+
model: str | None = None,
|
|
340
|
+
dimensions: int | None = None,
|
|
341
|
+
) -> list[list[float]]:
|
|
342
|
+
"""
|
|
343
|
+
Get embedding vectors for multiple texts in a single API call.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
texts: List of texts to get embeddings for.
|
|
347
|
+
model: Optional embedding model. When omitted, the model is
|
|
348
|
+
auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
|
|
349
|
+
so callers inherit the local-embedder gate and any non-OpenAI
|
|
350
|
+
provider configured for this client.
|
|
351
|
+
dimensions: Optional number of dimensions for the embedding vectors.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
List of embedding vectors, one per input text, in the same order as input.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
LiteLLMClientError: If embedding generation fails.
|
|
358
|
+
"""
|
|
359
|
+
if not texts:
|
|
360
|
+
return []
|
|
361
|
+
|
|
362
|
+
embedding_model = model or self._resolve_default_embedding_model()
|
|
363
|
+
mode = embedding_provider_mode(embedding_model)
|
|
364
|
+
if mode == "off":
|
|
365
|
+
raise EmbeddingUnavailableError("Embedding provider is disabled")
|
|
366
|
+
if should_use_embedding_service(embedding_model):
|
|
367
|
+
return get_service_embeddings(
|
|
368
|
+
list(texts), model=embedding_model, dimensions=dimensions
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# See matching short-circuits in get_embedding above.
|
|
372
|
+
if _is_nomic_model(embedding_model):
|
|
373
|
+
_reject_cloud_mode(embedding_model, mode)
|
|
374
|
+
try:
|
|
375
|
+
return NomicEmbedder.get().embed(list(texts))
|
|
376
|
+
except Exception as e:
|
|
377
|
+
raise LiteLLMClientError(
|
|
378
|
+
f"Nomic batch embedding generation failed: {str(e)}"
|
|
379
|
+
) from e
|
|
380
|
+
|
|
381
|
+
if embedding_model.startswith("local/"):
|
|
382
|
+
_reject_cloud_mode(embedding_model, mode)
|
|
383
|
+
if not _is_chromadb_importable():
|
|
384
|
+
raise LiteLLMClientError(
|
|
385
|
+
f"Embedding model {embedding_model!r} requires chromadb. "
|
|
386
|
+
"Run `pip install chromadb`."
|
|
387
|
+
)
|
|
388
|
+
try:
|
|
389
|
+
return LocalEmbedder.get().embed(list(texts))
|
|
390
|
+
except Exception as e:
|
|
391
|
+
raise LiteLLMClientError(
|
|
392
|
+
f"Local batch embedding generation failed: {str(e)}"
|
|
393
|
+
) from e
|
|
394
|
+
|
|
395
|
+
texts = [_truncate_for_embedding(t, embedding_model) for t in texts]
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
params = {"model": embedding_model, "input": texts}
|
|
399
|
+
if dimensions:
|
|
400
|
+
params["dimensions"] = dimensions
|
|
401
|
+
|
|
402
|
+
# Resolve and add API key configuration if provided (overrides env vars)
|
|
403
|
+
api_key, api_base, api_version = self._resolve_api_key(
|
|
404
|
+
embedding_model, for_embedding=True
|
|
405
|
+
)
|
|
406
|
+
if api_key:
|
|
407
|
+
params["api_key"] = api_key
|
|
408
|
+
if api_base:
|
|
409
|
+
params["api_base"] = api_base
|
|
410
|
+
if api_version:
|
|
411
|
+
params["api_version"] = api_version
|
|
412
|
+
|
|
413
|
+
response = litellm.embedding(
|
|
414
|
+
**params,
|
|
415
|
+
timeout=self.config.timeout,
|
|
416
|
+
num_retries=self.config.max_retries,
|
|
417
|
+
)
|
|
418
|
+
# Response data may not be in order, sort by index to ensure correct ordering
|
|
419
|
+
sorted_data = sorted(response.data, key=lambda x: x["index"])
|
|
420
|
+
return [item["embedding"] for item in sorted_data]
|
|
421
|
+
except Exception as e:
|
|
422
|
+
raise LiteLLMClientError(
|
|
423
|
+
f"Batch embedding generation failed: {str(e)}"
|
|
424
|
+
) from e
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Pure JSON-extraction/repair helpers for structured-output parsing (Tier-2.5 leaf).
|
|
2
|
+
|
|
3
|
+
Stateless leaf module: these six functions never touched ``self`` on the former
|
|
4
|
+
``LiteLLMClient`` (they only called each other), so they move to a dependency-free
|
|
5
|
+
leaf. ``_maybe_parse_structured_output`` (StructuredOutputMixin) calls them.
|
|
6
|
+
|
|
7
|
+
Bodies are moved VERBATIM from the former ``litellm_client.py`` methods — the only
|
|
8
|
+
change is dropping the (unused) ``self`` parameter and rewriting the intra-cluster
|
|
9
|
+
``self._x(...)`` calls to direct calls. No behavior change.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Python-to-JSON keyword replacements used by _sanitize_json_string.
|
|
16
|
+
_PYTHON_TO_JSON_REPLACEMENTS = {"True": "true", "False": "false", "None": "null"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_json_from_string(content: str) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Extract JSON from a string, handling markdown code blocks.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
content: String potentially containing JSON.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Extracted JSON string.
|
|
28
|
+
"""
|
|
29
|
+
content = content.strip()
|
|
30
|
+
|
|
31
|
+
# Prefer a balanced JSON container first. Structured JSON may contain
|
|
32
|
+
# markdown fences inside string values; grabbing the first code block
|
|
33
|
+
# would extract the inner snippet instead of the response object.
|
|
34
|
+
json_container = _extract_first_json_container(content)
|
|
35
|
+
if json_container is not None:
|
|
36
|
+
return json_container
|
|
37
|
+
|
|
38
|
+
# Try to extract from markdown code blocks
|
|
39
|
+
json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
|
|
40
|
+
matches = re.findall(json_block_pattern, content)
|
|
41
|
+
if matches:
|
|
42
|
+
return matches[0].strip()
|
|
43
|
+
|
|
44
|
+
return content
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_first_json_container(content: str) -> str | None:
|
|
48
|
+
"""Return the first balanced JSON-like object/array in ``content``."""
|
|
49
|
+
for start_idx, ch in enumerate(content):
|
|
50
|
+
if ch not in "{[":
|
|
51
|
+
continue
|
|
52
|
+
end_idx = _find_json_container_end(content, start_idx)
|
|
53
|
+
if end_idx is None:
|
|
54
|
+
continue
|
|
55
|
+
candidate = content[start_idx : end_idx + 1]
|
|
56
|
+
if _is_parseable_json_candidate(candidate):
|
|
57
|
+
return candidate
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _find_json_container_end(content: str, start_idx: int) -> int | None:
|
|
62
|
+
"""Find the matching end of a JSON container, respecting strings."""
|
|
63
|
+
pairs = {"{": "}", "[": "]"}
|
|
64
|
+
stack = [pairs[content[start_idx]]]
|
|
65
|
+
in_str = False
|
|
66
|
+
escape = False
|
|
67
|
+
|
|
68
|
+
for idx in range(start_idx + 1, len(content)):
|
|
69
|
+
ch = content[idx]
|
|
70
|
+
if escape:
|
|
71
|
+
escape = False
|
|
72
|
+
continue
|
|
73
|
+
if ch == "\\" and in_str:
|
|
74
|
+
escape = True
|
|
75
|
+
continue
|
|
76
|
+
if ch == '"':
|
|
77
|
+
in_str = not in_str
|
|
78
|
+
continue
|
|
79
|
+
if in_str:
|
|
80
|
+
continue
|
|
81
|
+
if ch in pairs:
|
|
82
|
+
stack.append(pairs[ch])
|
|
83
|
+
elif ch in ("}", "]"):
|
|
84
|
+
if not stack or stack.pop() != ch:
|
|
85
|
+
return None
|
|
86
|
+
if not stack:
|
|
87
|
+
return idx
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_parseable_json_candidate(candidate: str) -> bool:
|
|
92
|
+
"""Return True if a balanced candidate can parse after normal sanitizing."""
|
|
93
|
+
try:
|
|
94
|
+
json.loads(candidate)
|
|
95
|
+
return True
|
|
96
|
+
except Exception:
|
|
97
|
+
try:
|
|
98
|
+
json.loads(_sanitize_json_string(candidate))
|
|
99
|
+
return True
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _looks_truncated_json(json_str: str) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Return True when a JSON-like string appears to end before it is complete.
|
|
107
|
+
|
|
108
|
+
This intentionally only treats content with a JSON container opener as
|
|
109
|
+
truncation. Plain text that is not JSON should proceed to the normal
|
|
110
|
+
parse failure path.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
json_str: Extracted JSON-like response text.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if the response has unclosed containers or strings.
|
|
117
|
+
"""
|
|
118
|
+
stripped = json_str.strip()
|
|
119
|
+
start_indices = [
|
|
120
|
+
idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1
|
|
121
|
+
]
|
|
122
|
+
if not stripped or not start_indices:
|
|
123
|
+
return False
|
|
124
|
+
stripped = stripped[min(start_indices) :]
|
|
125
|
+
|
|
126
|
+
stack: list[str] = []
|
|
127
|
+
in_str = False
|
|
128
|
+
escape = False
|
|
129
|
+
pairs = {"{": "}", "[": "]"}
|
|
130
|
+
|
|
131
|
+
for ch in stripped:
|
|
132
|
+
if escape:
|
|
133
|
+
escape = False
|
|
134
|
+
continue
|
|
135
|
+
if ch == "\\" and in_str:
|
|
136
|
+
escape = True
|
|
137
|
+
continue
|
|
138
|
+
if ch == '"':
|
|
139
|
+
in_str = not in_str
|
|
140
|
+
continue
|
|
141
|
+
if in_str:
|
|
142
|
+
continue
|
|
143
|
+
if ch in pairs:
|
|
144
|
+
stack.append(pairs[ch])
|
|
145
|
+
elif ch in ("}", "]") and (not stack or stack.pop() != ch):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
return in_str or bool(stack)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _sanitize_json_string(json_str: str) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Sanitize a JSON-like string that uses Python-style syntax into valid JSON.
|
|
154
|
+
|
|
155
|
+
Handles common LLM issues: single quotes, Python True/False/None,
|
|
156
|
+
and trailing commas before closing braces/brackets.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
json_str: A JSON-like string that may contain Python-style syntax.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A sanitized string closer to valid JSON.
|
|
163
|
+
"""
|
|
164
|
+
s = json_str
|
|
165
|
+
|
|
166
|
+
# Walk character-by-character to:
|
|
167
|
+
# 1. Replace single-quoted strings with double-quoted strings
|
|
168
|
+
# 2. Replace Python True/False/None with JSON true/false/null ONLY outside strings
|
|
169
|
+
# 3. Handle escaped apostrophes inside single-quoted strings (e.g. 'didn\'t')
|
|
170
|
+
# 4. Escape literal double quotes that end up inside double-quoted strings
|
|
171
|
+
result = []
|
|
172
|
+
in_double = False
|
|
173
|
+
in_single = False
|
|
174
|
+
i = 0
|
|
175
|
+
while i < len(s):
|
|
176
|
+
ch = s[i]
|
|
177
|
+
if ch == "\\" and (in_double or in_single):
|
|
178
|
+
# Escaped character inside a string
|
|
179
|
+
if i + 1 < len(s):
|
|
180
|
+
next_ch = s[i + 1]
|
|
181
|
+
if in_single and next_ch == "'":
|
|
182
|
+
# \' inside single-quoted string → literal apostrophe
|
|
183
|
+
# In JSON double-quoted strings, apostrophe needs no escape
|
|
184
|
+
result.append("'")
|
|
185
|
+
i += 2
|
|
186
|
+
continue
|
|
187
|
+
result.append(ch)
|
|
188
|
+
result.append(next_ch)
|
|
189
|
+
i += 2
|
|
190
|
+
continue
|
|
191
|
+
result.append(ch)
|
|
192
|
+
elif ch == '"' and not in_single:
|
|
193
|
+
in_double = not in_double
|
|
194
|
+
result.append(ch)
|
|
195
|
+
elif ch == "'" and not in_double:
|
|
196
|
+
in_single = not in_single
|
|
197
|
+
result.append('"') # swap single → double
|
|
198
|
+
else:
|
|
199
|
+
# Escape unescaped double quotes inside single-quoted strings
|
|
200
|
+
# (they become part of a double-quoted JSON string)
|
|
201
|
+
if in_single and ch == '"':
|
|
202
|
+
result.append('\\"')
|
|
203
|
+
else:
|
|
204
|
+
result.append(ch)
|
|
205
|
+
i += 1
|
|
206
|
+
s = "".join(result)
|
|
207
|
+
|
|
208
|
+
# Replace Python booleans/None with JSON equivalents only outside quoted strings.
|
|
209
|
+
# We walk the already-double-quoted result so we only need to track double quotes.
|
|
210
|
+
output = []
|
|
211
|
+
in_str = False
|
|
212
|
+
j = 0
|
|
213
|
+
while j < len(s):
|
|
214
|
+
if s[j] == "\\" and in_str:
|
|
215
|
+
output.append(s[j : j + 2])
|
|
216
|
+
j += 2
|
|
217
|
+
continue
|
|
218
|
+
if s[j] == '"':
|
|
219
|
+
in_str = not in_str
|
|
220
|
+
output.append(s[j])
|
|
221
|
+
j += 1
|
|
222
|
+
continue
|
|
223
|
+
if not in_str:
|
|
224
|
+
matched = False
|
|
225
|
+
for py_val, json_val in _PYTHON_TO_JSON_REPLACEMENTS.items():
|
|
226
|
+
if s[j : j + len(py_val)] == py_val:
|
|
227
|
+
# Check word boundaries
|
|
228
|
+
before = s[j - 1] if j > 0 else " "
|
|
229
|
+
after = s[j + len(py_val)] if j + len(py_val) < len(s) else " "
|
|
230
|
+
if (
|
|
231
|
+
not before.isalnum()
|
|
232
|
+
and before != "_"
|
|
233
|
+
and not after.isalnum()
|
|
234
|
+
and after != "_"
|
|
235
|
+
):
|
|
236
|
+
output.append(json_val)
|
|
237
|
+
j += len(py_val)
|
|
238
|
+
matched = True
|
|
239
|
+
break
|
|
240
|
+
if not matched:
|
|
241
|
+
output.append(s[j])
|
|
242
|
+
j += 1
|
|
243
|
+
else:
|
|
244
|
+
output.append(s[j])
|
|
245
|
+
j += 1
|
|
246
|
+
s = "".join(output)
|
|
247
|
+
|
|
248
|
+
# Remove trailing commas before } or ]
|
|
249
|
+
return re.sub(r",\s*([}\]])", r"\1", s)
|