latris 0.3.0__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.
- latris/__init__.py +1 -0
- latris/__main__.py +4 -0
- latris/_mutation_signal.py +34 -0
- latris/ai/__init__.py +0 -0
- latris/ai/adapters/__init__.py +43 -0
- latris/ai/adapters/base.py +88 -0
- latris/ai/adapters/litellm_adapter.py +272 -0
- latris/ai/adapters/mock_adapter.py +105 -0
- latris/ai/call.py +442 -0
- latris/ai/chain_runner.py +155 -0
- latris/ai/chains.py +121 -0
- latris/ai/config.py +127 -0
- latris/ai/context.py +2695 -0
- latris/ai/credentials.py +145 -0
- latris/ai/interactions.py +187 -0
- latris/ai/prompts.py +1940 -0
- latris/ai/proposals.py +1857 -0
- latris/ai/render.py +412 -0
- latris/ai/validate_urls.py +89 -0
- latris/cli/__init__.py +0 -0
- latris/cli/_errors.py +60 -0
- latris/cli/ai_cmd.py +733 -0
- latris/cli/analyse_cmd.py +220 -0
- latris/cli/app.py +151 -0
- latris/cli/attr_cmd.py +26 -0
- latris/cli/bn_cmd.py +352 -0
- latris/cli/challenge_cmd.py +119 -0
- latris/cli/check_cmd.py +51 -0
- latris/cli/completeness_cmd.py +139 -0
- latris/cli/edge_cmd.py +65 -0
- latris/cli/elicit_cmd.py +144 -0
- latris/cli/evidence_cmd.py +39 -0
- latris/cli/export_cmd.py +156 -0
- latris/cli/fcm_cmd.py +112 -0
- latris/cli/findings_cmd.py +125 -0
- latris/cli/history_cmd.py +81 -0
- latris/cli/init_cmd.py +48 -0
- latris/cli/interpret_cmd.py +203 -0
- latris/cli/lineage_cmd.py +237 -0
- latris/cli/mapping_cmd.py +72 -0
- latris/cli/maturity_cmd.py +49 -0
- latris/cli/node_cmd.py +85 -0
- latris/cli/project_cmd.py +113 -0
- latris/cli/provenance_cmd.py +168 -0
- latris/cli/ref_cmd.py +137 -0
- latris/cli/review_cmd.py +36 -0
- latris/cli/story_cmd.py +144 -0
- latris/cli/sync_cmd.py +79 -0
- latris/cli/view_cmd.py +30 -0
- latris/diff/__init__.py +0 -0
- latris/diff/semantic.py +680 -0
- latris/exporters/__init__.py +0 -0
- latris/exporters/_format.py +10 -0
- latris/exporters/_scope.py +31 -0
- latris/exporters/diagram.py +106 -0
- latris/exporters/dot.py +92 -0
- latris/exporters/markdown.py +728 -0
- latris/exporters/narrative.py +302 -0
- latris/exporters/packs.py +70 -0
- latris/exporters/review_html.py +698 -0
- latris/exporters/story_diagram.py +377 -0
- latris/exporters/story_html.py +134 -0
- latris/exporters/templates/story/story.html.jinja2 +91 -0
- latris/exporters/templates/story/themes/default.css +63 -0
- latris/exporters/templates/story/vendor/scrollama.min.js +1 -0
- latris/exporters/word.py +538 -0
- latris/operations/__init__.py +0 -0
- latris/operations/_activations.py +93 -0
- latris/operations/_actor.py +22 -0
- latris/operations/_attribute_helpers.py +49 -0
- latris/operations/_bn_canonical.py +103 -0
- latris/operations/_bn_engine.py +125 -0
- latris/operations/_chunking.py +162 -0
- latris/operations/_data_engine.py +854 -0
- latris/operations/_graph_checks.py +41 -0
- latris/operations/_logs.py +22 -0
- latris/operations/_resolve.py +75 -0
- latris/operations/activation.py +63 -0
- latris/operations/ai_bibliography.py +152 -0
- latris/operations/analysis.py +134 -0
- latris/operations/analysis_runs.py +128 -0
- latris/operations/attributes.py +157 -0
- latris/operations/batch.py +98 -0
- latris/operations/bn.py +339 -0
- latris/operations/bn_inference.py +97 -0
- latris/operations/bn_learn.py +321 -0
- latris/operations/bn_sensitivity.py +165 -0
- latris/operations/challenges.py +157 -0
- latris/operations/coherence_checks.py +234 -0
- latris/operations/commit.py +89 -0
- latris/operations/completeness.py +340 -0
- latris/operations/compose.py +395 -0
- latris/operations/configure.py +119 -0
- latris/operations/datasets.py +658 -0
- latris/operations/edges.py +101 -0
- latris/operations/element_history.py +162 -0
- latris/operations/elicitation.py +216 -0
- latris/operations/evidence.py +277 -0
- latris/operations/fcm_inference.py +196 -0
- latris/operations/findings.py +683 -0
- latris/operations/graph_analysis.py +792 -0
- latris/operations/init.py +154 -0
- latris/operations/interpret.py +815 -0
- latris/operations/interpret_registry.py +176 -0
- latris/operations/lineage.py +779 -0
- latris/operations/mappings.py +130 -0
- latris/operations/maturity.py +332 -0
- latris/operations/nodes.py +121 -0
- latris/operations/project.py +189 -0
- latris/operations/provenance.py +261 -0
- latris/operations/readiness.py +918 -0
- latris/operations/references.py +659 -0
- latris/operations/report_config.py +66 -0
- latris/operations/review.py +213 -0
- latris/operations/story.py +313 -0
- latris/operations/sync.py +698 -0
- latris/operations/view_attributes.py +127 -0
- latris/operations/view_roles.py +74 -0
- latris/operations/views.py +137 -0
- latris/perf.py +98 -0
- latris/repo/__init__.py +0 -0
- latris/repo/git.py +547 -0
- latris/repo/layout.py +331 -0
- latris/repo/lineage_index.py +110 -0
- latris/repo/project.py +36 -0
- latris/repo/yaml_io.py +149 -0
- latris/schema/__init__.py +297 -0
- latris/schema/ai.py +91 -0
- latris/schema/analysis.py +46 -0
- latris/schema/attributes.py +182 -0
- latris/schema/bn.py +227 -0
- latris/schema/common.py +104 -0
- latris/schema/completeness.py +60 -0
- latris/schema/data_actions.py +91 -0
- latris/schema/data_versions.py +38 -0
- latris/schema/datasets.py +61 -0
- latris/schema/elicitation.py +102 -0
- latris/schema/findings.py +100 -0
- latris/schema/ids.py +98 -0
- latris/schema/interpretation.py +108 -0
- latris/schema/layout_ui.py +27 -0
- latris/schema/lineage.py +61 -0
- latris/schema/maturity.py +37 -0
- latris/schema/membership.py +33 -0
- latris/schema/model_types.py +312 -0
- latris/schema/project.py +30 -0
- latris/schema/provenance.py +154 -0
- latris/schema/references.py +55 -0
- latris/schema/report_config.py +55 -0
- latris/schema/review.py +88 -0
- latris/schema/roles.py +31 -0
- latris/schema/settings.py +67 -0
- latris/schema/story.py +129 -0
- latris/schema/substrate.py +171 -0
- latris/schema/views.py +50 -0
- latris/server/__init__.py +1 -0
- latris/server/__main__.py +39 -0
- latris/server/app.py +687 -0
- latris/server/config.py +16 -0
- latris/server/deps.py +26 -0
- latris/server/entry.py +120 -0
- latris/server/help.py +1236 -0
- latris/server/perf.py +80 -0
- latris/server/routes/__init__.py +1 -0
- latris/server/routes/activity.py +20 -0
- latris/server/routes/ai.py +484 -0
- latris/server/routes/analysis.py +114 -0
- latris/server/routes/annotations.py +94 -0
- latris/server/routes/attributes.py +30 -0
- latris/server/routes/bn.py +234 -0
- latris/server/routes/challenges.py +59 -0
- latris/server/routes/checks.py +35 -0
- latris/server/routes/completeness.py +94 -0
- latris/server/routes/compose.py +90 -0
- latris/server/routes/configure.py +50 -0
- latris/server/routes/datasets.py +213 -0
- latris/server/routes/edges.py +49 -0
- latris/server/routes/evidence.py +61 -0
- latris/server/routes/export.py +86 -0
- latris/server/routes/fcm.py +75 -0
- latris/server/routes/findings.py +133 -0
- latris/server/routes/help.py +28 -0
- latris/server/routes/history.py +39 -0
- latris/server/routes/interpret.py +190 -0
- latris/server/routes/layout.py +38 -0
- latris/server/routes/lineage.py +205 -0
- latris/server/routes/model_choices.py +57 -0
- latris/server/routes/model_types.py +40 -0
- latris/server/routes/nodes.py +67 -0
- latris/server/routes/provenance.py +111 -0
- latris/server/routes/readiness.py +121 -0
- latris/server/routes/references.py +152 -0
- latris/server/routes/report_config.py +40 -0
- latris/server/routes/review.py +31 -0
- latris/server/routes/story.py +126 -0
- latris/server/routes/sync.py +219 -0
- latris/server/routes/view_attributes.py +49 -0
- latris/server/routes/views.py +64 -0
- latris/server/state.py +160 -0
- latris/server/watch.py +133 -0
- latris/sync/__init__.py +5 -0
- latris/sync/askpass.py +54 -0
- latris/sync/client.py +156 -0
- latris/sync/credentials.py +112 -0
- latris-0.3.0.dist-info/METADATA +232 -0
- latris-0.3.0.dist-info/RECORD +209 -0
- latris-0.3.0.dist-info/WHEEL +4 -0
- latris-0.3.0.dist-info/entry_points.txt +2 -0
- latris-0.3.0.dist-info/licenses/LICENSE +201 -0
latris/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
latris/__main__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Cross-layer signal that a server-initiated mutation just happened.
|
|
2
|
+
|
|
3
|
+
Lives at the package root (alongside :mod:`latris.perf`) so
|
|
4
|
+
``operations/commit.py`` can fire it from ``auto_commit`` without
|
|
5
|
+
importing from the ``server/`` layer. The file watcher reads this
|
|
6
|
+
signal to suppress redundant ``state_changed`` broadcasts after the
|
|
7
|
+
GUI's own mutation response has already rehydrated its store.
|
|
8
|
+
|
|
9
|
+
The mechanism is a 2-second wall-clock window: any file change
|
|
10
|
+
detected within :data:`SUPPRESS_SECONDS` of the last
|
|
11
|
+
:func:`mark_own_mutation` call is treated as our own write and
|
|
12
|
+
silently dropped. The window is intentionally short — external
|
|
13
|
+
mutations (CLI runs, another editor) still get a broadcast as soon
|
|
14
|
+
as the window expires.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
SUPPRESS_SECONDS = 2.0
|
|
22
|
+
|
|
23
|
+
_last_own_mutation: float = 0.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def mark_own_mutation() -> None:
|
|
27
|
+
"""Stamp the suppression window. Called by ``auto_commit`` on a real commit."""
|
|
28
|
+
global _last_own_mutation # noqa: PLW0603
|
|
29
|
+
_last_own_mutation = time.monotonic()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_suppressed() -> bool:
|
|
33
|
+
"""True if a server mutation happened within the suppression window."""
|
|
34
|
+
return (time.monotonic() - _last_own_mutation) < SUPPRESS_SECONDS
|
latris/ai/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Chat adapters — how we get a typed proposal out of an LLM.
|
|
2
|
+
|
|
3
|
+
The rest of the codebase only ever imports ``get_adapter()`` from here.
|
|
4
|
+
Selection is environment-driven so tests and CI can run without network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from latris.ai.adapters.base import ChatAdapter, ChatResult
|
|
12
|
+
from latris.ai.adapters.mock_adapter import MockAdapter
|
|
13
|
+
|
|
14
|
+
MOCK_ENV_VAR = "LATRIS_AI_MOCK"
|
|
15
|
+
# One-release back-compat: environments exported before the rename (and the
|
|
16
|
+
# private app repo's CI during the transition) may still set the old name.
|
|
17
|
+
_LEGACY_MOCK_ENV_VAR = "GRAPH_TOOLING_AI_MOCK"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mock_enabled() -> bool:
|
|
21
|
+
"""True when AI mocking is requested via env: ``LATRIS_AI_MOCK``, falling
|
|
22
|
+
back to the pre-rename ``GRAPH_TOOLING_AI_MOCK`` for one release."""
|
|
23
|
+
value = os.environ.get(MOCK_ENV_VAR) or os.environ.get(_LEGACY_MOCK_ENV_VAR) or ""
|
|
24
|
+
return value.lower() in ("1", "true", "yes")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_adapter() -> ChatAdapter:
|
|
28
|
+
"""Return the active chat adapter.
|
|
29
|
+
|
|
30
|
+
If ``LATRIS_AI_MOCK`` is truthy, return a ``MockAdapter`` — zero
|
|
31
|
+
network, deterministic responses from whatever's been registered via
|
|
32
|
+
``MockAdapter.register(...)``. Otherwise return the ``LiteLLMAdapter``.
|
|
33
|
+
"""
|
|
34
|
+
if mock_enabled():
|
|
35
|
+
return MockAdapter.shared()
|
|
36
|
+
# Lazy import: litellm is heavy and pulls in several transitive deps on
|
|
37
|
+
# first import. No reason to pay that cost in mock mode.
|
|
38
|
+
from latris.ai.adapters.litellm_adapter import LiteLLMAdapter
|
|
39
|
+
|
|
40
|
+
return LiteLLMAdapter()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["ChatAdapter", "ChatResult", "MockAdapter", "get_adapter", "mock_enabled"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The chat adapter interface — one chokepoint for every LLM call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Protocol, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T", bound=BaseModel)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LLMCallError(RuntimeError):
|
|
15
|
+
"""Provider-error wrapper with API keys scrubbed from the message.
|
|
16
|
+
|
|
17
|
+
Raised by adapters instead of propagating provider-specific exceptions
|
|
18
|
+
that may contain keys in URLs or headers. The CLI error handler catches
|
|
19
|
+
this and prints a clean, safe message.
|
|
20
|
+
|
|
21
|
+
``raw_response`` carries the model's last raw output when the adapter
|
|
22
|
+
can extract it (e.g. instructor's ``InstructorRetryException`` exposes
|
|
23
|
+
the failed completion under ``last_completion``). It exists so the
|
|
24
|
+
interaction logger can persist the malformed payload for diagnosis
|
|
25
|
+
rather than throwing it away with the exception.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, *, raw_response: str | None = None) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.raw_response = raw_response
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Known API-key shapes. Order matters — the generic ``sk-`` must come last.
|
|
34
|
+
_KEY_PATTERNS = [
|
|
35
|
+
re.compile(r"AIza[0-9A-Za-z_-]{20,}"), # Google AI Studio / Gemini
|
|
36
|
+
re.compile(r"sk-ant-(?:api\d+-)?[0-9A-Za-z_-]{20,}"), # Anthropic
|
|
37
|
+
re.compile(r"sk-proj-[0-9A-Za-z_-]{20,}"), # OpenAI project keys
|
|
38
|
+
re.compile(r"sk-svcacct-[0-9A-Za-z_-]{20,}"), # OpenAI service accounts
|
|
39
|
+
re.compile(r"gsk_[0-9A-Za-z_-]{20,}"), # Groq
|
|
40
|
+
re.compile(r"xai-[0-9A-Za-z_-]{20,}"), # xAI
|
|
41
|
+
re.compile(r"[?&]key=[0-9A-Za-z_-]{20,}"), # any ?key=... in URLs
|
|
42
|
+
re.compile(r"sk-[0-9A-Za-z_-]{20,}"), # OpenAI legacy (keep last)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def redact_secrets(text: str) -> str:
|
|
47
|
+
"""Scrub anything that looks like an API key. Safe to call on tracebacks."""
|
|
48
|
+
if not text:
|
|
49
|
+
return text
|
|
50
|
+
for pat in _KEY_PATTERNS:
|
|
51
|
+
text = pat.sub("<REDACTED>", text)
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ChatResult:
|
|
57
|
+
"""What every adapter returns.
|
|
58
|
+
|
|
59
|
+
- ``parsed`` is the pydantic-validated structured output (what the caller
|
|
60
|
+
actually wanted — e.g. a ``ChallengeProposal``).
|
|
61
|
+
- ``raw`` is the model's full text response, stashed for the interaction
|
|
62
|
+
log so a future retrieval layer has verbatim memory.
|
|
63
|
+
- ``token_usage`` is provider-reported token counts; shape is free-form
|
|
64
|
+
but we standardise on ``input_tokens`` / ``output_tokens`` /
|
|
65
|
+
``cache_read_tokens`` / ``cache_write_tokens`` where applicable.
|
|
66
|
+
- ``cost_usd`` is the estimated dollar cost for this call, if available.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
parsed: BaseModel
|
|
70
|
+
raw: str
|
|
71
|
+
model_id: str
|
|
72
|
+
token_usage: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
cost_usd: float | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ChatAdapter(Protocol):
|
|
77
|
+
"""Minimum contract: ask a model for a typed response."""
|
|
78
|
+
|
|
79
|
+
def chat(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
model: str,
|
|
83
|
+
system: str,
|
|
84
|
+
user: str,
|
|
85
|
+
response_model: type[T],
|
|
86
|
+
temperature: float = 0.3,
|
|
87
|
+
max_tokens: int | None = None,
|
|
88
|
+
) -> ChatResult: ...
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Real LLM calls via litellm + instructor.
|
|
2
|
+
|
|
3
|
+
litellm normalises the chat-completion API across Anthropic / OpenAI /
|
|
4
|
+
Gemini / Groq / Ollama / etc. Instructor wraps it with pydantic-native
|
|
5
|
+
structured output (JSON schema + validation + retry on bad output).
|
|
6
|
+
|
|
7
|
+
We never store keys here — litellm reads them from env vars, which the
|
|
8
|
+
call orchestrator populates from keyring just before each call and unsets
|
|
9
|
+
after.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from latris.ai import config as ai_config
|
|
19
|
+
from latris.ai.adapters.base import ChatResult, LLMCallError, redact_secrets
|
|
20
|
+
from latris.perf import span
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T", bound=BaseModel)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_json_parse_error(exc: BaseException) -> bool:
|
|
26
|
+
"""Heuristic: did this exception come from a malformed-JSON response?
|
|
27
|
+
|
|
28
|
+
Pydantic's ValidationError for JSON-mode structured output surfaces
|
|
29
|
+
the message ``"Invalid JSON: ..."`` (see
|
|
30
|
+
https://errors.pydantic.dev/2.12/v/json_invalid). Instructor wraps
|
|
31
|
+
the validation error and propagates it; the substring match here is
|
|
32
|
+
deliberately broad — false positives just spend one extra call,
|
|
33
|
+
false negatives leak through to fail-loud where Layer 1 records
|
|
34
|
+
them. Both error message and class name are searched because
|
|
35
|
+
instructor's wrapper sometimes obscures the inner message.
|
|
36
|
+
"""
|
|
37
|
+
text = f"{type(exc).__name__}: {exc}".lower()
|
|
38
|
+
return "invalid json" in text or "jsondecodeerror" in text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_repair_messages(
|
|
42
|
+
*,
|
|
43
|
+
system: str,
|
|
44
|
+
user: str,
|
|
45
|
+
raw_response: str,
|
|
46
|
+
parser_error: str,
|
|
47
|
+
response_model: type[BaseModel],
|
|
48
|
+
) -> list[dict[str, str]]:
|
|
49
|
+
"""Construct the message list for a self-correction retry.
|
|
50
|
+
|
|
51
|
+
The model gets to see what it produced and the parser's complaint,
|
|
52
|
+
so it can fix the structural issue (typically an unescaped quote
|
|
53
|
+
or newline inside a string value) rather than re-generating
|
|
54
|
+
blindly. We cap the embedded raw response and parser error to
|
|
55
|
+
keep the repair prompt from approaching the context window in
|
|
56
|
+
pathological cases.
|
|
57
|
+
"""
|
|
58
|
+
raw_excerpt = raw_response[:4000]
|
|
59
|
+
err_excerpt = parser_error[:1000]
|
|
60
|
+
repair_user = (
|
|
61
|
+
f"{user}\n\n"
|
|
62
|
+
"Your previous response could not be parsed as a valid JSON "
|
|
63
|
+
f"object matching the {response_model.__name__} schema:\n\n"
|
|
64
|
+
f"<failed_response>\n{raw_excerpt}\n</failed_response>\n\n"
|
|
65
|
+
"Parser error:\n"
|
|
66
|
+
f"<parser_error>\n{err_excerpt}\n</parser_error>\n\n"
|
|
67
|
+
"Re-emit the same intent as a valid JSON object. Common "
|
|
68
|
+
"issues to check: unescaped double quotes inside string "
|
|
69
|
+
'values (use \\"), literal newlines inside string values '
|
|
70
|
+
"(use \\n), stray backslashes (use \\\\), and trailing "
|
|
71
|
+
"commas. Output only the JSON object, no prose around it."
|
|
72
|
+
)
|
|
73
|
+
return [
|
|
74
|
+
{"role": "system", "content": system},
|
|
75
|
+
{"role": "user", "content": repair_user},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LiteLLMAdapter:
|
|
80
|
+
"""Lazy-imported litellm+instructor. Constructed once per process."""
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
# Import lazily so mock-mode runs don't pay the litellm import cost.
|
|
84
|
+
import instructor # type: ignore[import-untyped]
|
|
85
|
+
import litellm # type: ignore[import-untyped]
|
|
86
|
+
|
|
87
|
+
self._instructor = instructor
|
|
88
|
+
self._instructor_client_tools = instructor.from_litellm(litellm.completion)
|
|
89
|
+
self._instructor_client_json = instructor.from_litellm(
|
|
90
|
+
litellm.completion,
|
|
91
|
+
mode=instructor.Mode.JSON,
|
|
92
|
+
)
|
|
93
|
+
self._litellm = litellm
|
|
94
|
+
|
|
95
|
+
def chat(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
model: str,
|
|
99
|
+
system: str,
|
|
100
|
+
user: str,
|
|
101
|
+
response_model: type[T],
|
|
102
|
+
temperature: float = 0.3,
|
|
103
|
+
max_tokens: int | None = None,
|
|
104
|
+
) -> ChatResult:
|
|
105
|
+
messages = [
|
|
106
|
+
{"role": "system", "content": system},
|
|
107
|
+
{"role": "user", "content": user},
|
|
108
|
+
]
|
|
109
|
+
# `create_with_completion` returns (parsed_model, raw_completion) so we
|
|
110
|
+
# can keep the verbatim response for the interaction log. Any provider
|
|
111
|
+
# error is re-wrapped as an ``LLMCallError`` with API keys scrubbed.
|
|
112
|
+
#
|
|
113
|
+
# Gemini models fail with Mode.TOOLS on complex schemas (optional/union
|
|
114
|
+
# fields return empty completions); Moonshot's thinking models reject
|
|
115
|
+
# the explicit tool_choice TOOLS mode sends ("tool_choice 'specified'
|
|
116
|
+
# is incompatible with thinking enabled"). Both use Mode.JSON.
|
|
117
|
+
lowered = model.lower()
|
|
118
|
+
uses_json_mode = lowered.startswith("gemini") or lowered.startswith("moonshot")
|
|
119
|
+
client = self._instructor_client_json if uses_json_mode else self._instructor_client_tools
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with span("adapter.chat", model=model):
|
|
123
|
+
parsed, raw_completion = self._call(
|
|
124
|
+
client=client,
|
|
125
|
+
model=model,
|
|
126
|
+
messages=messages,
|
|
127
|
+
response_model=response_model,
|
|
128
|
+
temperature=temperature,
|
|
129
|
+
max_tokens=max_tokens,
|
|
130
|
+
)
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
raw_response = self._extract_last_raw_response(exc)
|
|
133
|
+
# On parse failure, do one self-correction pass: feed the malformed
|
|
134
|
+
# output back to the model with the parser error and ask for valid
|
|
135
|
+
# JSON. Instructor's own retry doesn't show the model what it
|
|
136
|
+
# produced — that's the meaningful step-up.
|
|
137
|
+
if raw_response and _is_json_parse_error(exc):
|
|
138
|
+
try:
|
|
139
|
+
with span("adapter.chat.repair", model=model):
|
|
140
|
+
parsed, raw_completion = self._call(
|
|
141
|
+
client=client,
|
|
142
|
+
model=model,
|
|
143
|
+
messages=_build_repair_messages(
|
|
144
|
+
system=system,
|
|
145
|
+
user=user,
|
|
146
|
+
raw_response=raw_response,
|
|
147
|
+
parser_error=str(exc),
|
|
148
|
+
response_model=response_model,
|
|
149
|
+
),
|
|
150
|
+
response_model=response_model,
|
|
151
|
+
temperature=temperature,
|
|
152
|
+
max_tokens=max_tokens,
|
|
153
|
+
)
|
|
154
|
+
except Exception as repair_exc:
|
|
155
|
+
# Repair pass also failed — raise wrapping the *repair*
|
|
156
|
+
# error since that's the most recent (still safe — both
|
|
157
|
+
# error messages and raw response are scrubbed).
|
|
158
|
+
safe_message = redact_secrets(str(repair_exc))
|
|
159
|
+
repair_raw = self._extract_last_raw_response(repair_exc) or raw_response
|
|
160
|
+
raise LLMCallError(
|
|
161
|
+
f"{type(repair_exc).__name__} from {model} (repair pass): {safe_message}",
|
|
162
|
+
raw_response=redact_secrets(repair_raw) or None,
|
|
163
|
+
) from None
|
|
164
|
+
else:
|
|
165
|
+
safe_message = redact_secrets(str(exc))
|
|
166
|
+
raise LLMCallError(
|
|
167
|
+
f"{type(exc).__name__} from {model}: {safe_message}",
|
|
168
|
+
raw_response=redact_secrets(raw_response or "") or None,
|
|
169
|
+
) from None
|
|
170
|
+
|
|
171
|
+
token_usage = self._extract_token_usage(raw_completion)
|
|
172
|
+
cost_usd = self._try_completion_cost(raw_completion)
|
|
173
|
+
raw_text = self._first_text_content(raw_completion)
|
|
174
|
+
|
|
175
|
+
return ChatResult(
|
|
176
|
+
parsed=parsed,
|
|
177
|
+
raw=raw_text,
|
|
178
|
+
model_id=model,
|
|
179
|
+
token_usage=token_usage,
|
|
180
|
+
cost_usd=cost_usd,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _call(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
client: Any,
|
|
187
|
+
model: str,
|
|
188
|
+
messages: list[dict[str, str]],
|
|
189
|
+
response_model: type[T],
|
|
190
|
+
temperature: float,
|
|
191
|
+
max_tokens: int | None,
|
|
192
|
+
) -> tuple[T, Any]:
|
|
193
|
+
"""Single instructor invocation. Extracted so the repair pass can
|
|
194
|
+
reuse the same code path with different messages."""
|
|
195
|
+
kwargs: dict[str, Any] = {
|
|
196
|
+
"model": model,
|
|
197
|
+
"messages": messages,
|
|
198
|
+
"response_model": response_model,
|
|
199
|
+
"temperature": temperature,
|
|
200
|
+
# Bumped from 1 → 3 alongside Layer 2's repair pass. Instructor
|
|
201
|
+
# retries on validation error (missing field, wrong type) — a
|
|
202
|
+
# higher budget helps with transient hiccups before we burn the
|
|
203
|
+
# one repair attempt below on actual JSON-parse failures.
|
|
204
|
+
"max_retries": 3,
|
|
205
|
+
}
|
|
206
|
+
if max_tokens is not None:
|
|
207
|
+
kwargs["max_tokens"] = max_tokens
|
|
208
|
+
extra_body = ai_config.resolve_extra_body(model)
|
|
209
|
+
if extra_body:
|
|
210
|
+
kwargs["extra_body"] = extra_body
|
|
211
|
+
return client.chat.completions.create_with_completion(**kwargs)
|
|
212
|
+
|
|
213
|
+
# ---- helpers ----
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _extract_token_usage(completion: Any) -> dict[str, Any]:
|
|
217
|
+
usage = getattr(completion, "usage", None)
|
|
218
|
+
if usage is None:
|
|
219
|
+
return {}
|
|
220
|
+
# Usage fields vary per provider; collect the standard ones.
|
|
221
|
+
out: dict[str, Any] = {}
|
|
222
|
+
for src_key, dst_key in (
|
|
223
|
+
("prompt_tokens", "input_tokens"),
|
|
224
|
+
("completion_tokens", "output_tokens"),
|
|
225
|
+
("total_tokens", "total_tokens"),
|
|
226
|
+
("cache_read_input_tokens", "cache_read_tokens"),
|
|
227
|
+
("cache_creation_input_tokens", "cache_write_tokens"),
|
|
228
|
+
):
|
|
229
|
+
value = getattr(usage, src_key, None)
|
|
230
|
+
if value is not None:
|
|
231
|
+
out[dst_key] = value
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
def _try_completion_cost(self, completion: Any) -> float | None:
|
|
235
|
+
try:
|
|
236
|
+
return float(self._litellm.completion_cost(completion_response=completion))
|
|
237
|
+
except Exception:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _first_text_content(completion: Any) -> str:
|
|
242
|
+
try:
|
|
243
|
+
choices = getattr(completion, "choices", None) or []
|
|
244
|
+
if not choices:
|
|
245
|
+
return ""
|
|
246
|
+
msg = getattr(choices[0], "message", None)
|
|
247
|
+
return getattr(msg, "content", "") or ""
|
|
248
|
+
except Exception:
|
|
249
|
+
return ""
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def _extract_last_raw_response(cls, exc: BaseException) -> str | None:
|
|
253
|
+
"""Best-effort pull of the failed model output from instructor's exception.
|
|
254
|
+
|
|
255
|
+
Instructor's ``InstructorRetryException`` exposes the last completion
|
|
256
|
+
under ``last_completion`` (a chat-completion-shaped object). When
|
|
257
|
+
retries are exhausted on a parse failure, this is the malformed
|
|
258
|
+
payload we need for diagnosis. Falls back to ``messages`` /
|
|
259
|
+
``raw_response`` attribute names other instructor versions use.
|
|
260
|
+
"""
|
|
261
|
+
for attr in ("last_completion", "raw_response", "completion"):
|
|
262
|
+
completion = getattr(exc, attr, None)
|
|
263
|
+
if completion is None:
|
|
264
|
+
continue
|
|
265
|
+
text = cls._first_text_content(completion)
|
|
266
|
+
if text:
|
|
267
|
+
return text
|
|
268
|
+
# InstructorRetryException stores the rendered prompt under `messages`
|
|
269
|
+
# and the parser failure under `n_attempts` / `total_usage`; the raw
|
|
270
|
+
# text we want isn't always reachable. Last resort: stringify the
|
|
271
|
+
# exception's traceback tail.
|
|
272
|
+
return None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Deterministic mock adapter — hermetic for tests and demos.
|
|
2
|
+
|
|
3
|
+
Tests or dev sessions pre-register canned ``ChatResult``s keyed by prompt
|
|
4
|
+
signature; ``chat()`` looks the key up and returns the canned result.
|
|
5
|
+
Falls through to a generic stub if nothing is registered for the key —
|
|
6
|
+
useful for smoke runs where we just want no-network behaviour.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from latris.ai.adapters.base import ChatResult
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound=BaseModel)
|
|
19
|
+
|
|
20
|
+
_SHARED: MockAdapter | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _signature(model: str, system: str, user: str, response_cls: type[BaseModel]) -> str:
|
|
24
|
+
hasher = hashlib.sha256()
|
|
25
|
+
hasher.update(model.encode("utf-8"))
|
|
26
|
+
hasher.update(b"\x00")
|
|
27
|
+
hasher.update(system.encode("utf-8"))
|
|
28
|
+
hasher.update(b"\x00")
|
|
29
|
+
hasher.update(user.encode("utf-8"))
|
|
30
|
+
hasher.update(b"\x00")
|
|
31
|
+
hasher.update(response_cls.__name__.encode("utf-8"))
|
|
32
|
+
return hasher.hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MockAdapter:
|
|
36
|
+
"""In-memory fake. Not thread-safe; tests serialise their own state."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._registry: dict[str, ChatResult] = {}
|
|
40
|
+
self._default_by_response_cls: dict[str, ChatResult] = {}
|
|
41
|
+
|
|
42
|
+
# ---- registration ----
|
|
43
|
+
|
|
44
|
+
def register(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
model: str,
|
|
48
|
+
system: str,
|
|
49
|
+
user: str,
|
|
50
|
+
response_cls: type[BaseModel],
|
|
51
|
+
result: ChatResult,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Bind an exact ``(model, system, user, response_cls)`` to a canned result."""
|
|
54
|
+
self._registry[_signature(model, system, user, response_cls)] = result
|
|
55
|
+
|
|
56
|
+
def register_default(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
response_cls: type[BaseModel],
|
|
60
|
+
result: ChatResult,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Fallback for any call whose response_cls matches, regardless of prompt."""
|
|
63
|
+
self._default_by_response_cls[response_cls.__name__] = result
|
|
64
|
+
|
|
65
|
+
def clear(self) -> None:
|
|
66
|
+
self._registry.clear()
|
|
67
|
+
self._default_by_response_cls.clear()
|
|
68
|
+
|
|
69
|
+
# ---- adapter protocol ----
|
|
70
|
+
|
|
71
|
+
def chat(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
model: str,
|
|
75
|
+
system: str,
|
|
76
|
+
user: str,
|
|
77
|
+
response_model: type[T],
|
|
78
|
+
temperature: float = 0.3,
|
|
79
|
+
max_tokens: int | None = None,
|
|
80
|
+
) -> ChatResult:
|
|
81
|
+
key = _signature(model, system, user, response_model)
|
|
82
|
+
if key in self._registry:
|
|
83
|
+
return self._registry[key]
|
|
84
|
+
fallback = self._default_by_response_cls.get(response_model.__name__)
|
|
85
|
+
if fallback is not None:
|
|
86
|
+
return fallback
|
|
87
|
+
raise LookupError(
|
|
88
|
+
f"MockAdapter: no canned response registered for response_model="
|
|
89
|
+
f"{response_model.__name__} (model={model!r}). Register one via "
|
|
90
|
+
"MockAdapter.shared().register(...) / register_default(...)."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ---- process-wide singleton ----
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def shared(cls) -> MockAdapter:
|
|
97
|
+
global _SHARED
|
|
98
|
+
if _SHARED is None:
|
|
99
|
+
_SHARED = cls()
|
|
100
|
+
return _SHARED
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def reset_shared(cls) -> None:
|
|
104
|
+
global _SHARED
|
|
105
|
+
_SHARED = None
|