atif-sql 0.2.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.
Files changed (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Runtime configuration for atif-embed.
4
+
5
+ Pydantic v2 ``BaseSettings`` populated from env vars prefixed with
6
+ ``ATIF_SQL_`` (workspace convention). Two providers share one vector-store
7
+ contract, selected by ``ATIF_SQL_EMBED_PROVIDER``:
8
+
9
+ * ``cohere`` (the default): Cohere Embed v4 on Bedrock via the global CRIS
10
+ profile, 1024-dim Matryoshka, int8 documents, batch 96, concurrency 8.
11
+ * ``gemma``: EmbeddingGemma 2 on this machine, text-only, 768-dim (MRL
12
+ widths 512/256/128 too), through the optional ``local`` extra.
13
+
14
+ Each provider keeps its own default store under the corpus root, so switching
15
+ providers never meets the fail-loud ``(model, dim)`` guard on the other's
16
+ vectors, and both stores can live side by side.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+ from typing import Literal, Self
23
+
24
+ from pydantic import Field, model_validator
25
+ from pydantic_settings import BaseSettings, SettingsConfigDict
26
+
27
+ #: Matryoshka widths each provider can emit. Cohere Embed v4 takes these four
28
+ #: as ``output_dimension``; EmbeddingGemma 2 is trained for 768 natively and
29
+ #: for truncation to the other three (re-normalized after truncating).
30
+ PROVIDER_DIMENSIONS: dict[str, tuple[int, ...]] = {
31
+ "cohere": (256, 512, 1024, 1536),
32
+ "gemma": (768, 512, 256, 128),
33
+ }
34
+
35
+ #: Width a provider emits when ``output_dimension`` is unset.
36
+ DEFAULT_DIMENSION: dict[str, int] = {"cohere": 1024, "gemma": 768}
37
+
38
+ #: Store directory under the corpus root, per provider. Cohere keeps the name
39
+ #: every existing store already has.
40
+ STORE_DIRNAME: dict[str, str] = {
41
+ "cohere": "embeddings_lance",
42
+ "gemma": "embeddings_lance_gemma",
43
+ }
44
+
45
+
46
+ class EmbedSettings(BaseSettings):
47
+ """Env-driven settings for the embedding pipeline."""
48
+
49
+ model_config = SettingsConfigDict(
50
+ env_prefix="ATIF_SQL_",
51
+ env_file=".env",
52
+ extra="ignore",
53
+ )
54
+
55
+ #: Which embedder writes and queries the store: Cohere on Bedrock, or
56
+ #: EmbeddingGemma 2 on this machine.
57
+ embed_provider: Literal["cohere", "gemma"] = "cohere"
58
+ #: Cohere Embed v4 global CRIS profile on bedrock-runtime.
59
+ embed_model_id: str = "global.cohere.embed-v4:0"
60
+ #: Matryoshka output width; ``None`` takes the provider's default (1024
61
+ #: for Cohere, 768 for Gemma). An ``int`` checked against the provider's
62
+ #: widths rather than a ``Literal``, because pydantic refuses the string
63
+ #: an environment variable carries for an integer ``Literal``.
64
+ output_dimension: int | None = None
65
+ #: Cohere document storage type. int8 documents + float queries is the
66
+ #: adapter's asymmetry: docs are float-widened on the way out for the
67
+ #: FLOAT[dim] Lance column, queries always embed as float for the distance
68
+ #: math.
69
+ embedding_type: Literal["int8", "float", "uint8", "binary", "ubinary"] = "int8"
70
+ #: Max texts per invoke_model call (Cohere Embed v4 batch ceiling).
71
+ batch_size: int = 96
72
+ #: Concurrent Bedrock batches. 8 × batch 96 ran without throttling in
73
+ #: testing — Cohere's TPM bucket is the binding constraint.
74
+ embed_concurrency: int = 8
75
+ #: AWS region for the bedrock-runtime client.
76
+ region: str = "us-east-1"
77
+ #: Hugging Face repository of the local model, also the identity stamped
78
+ #: on every row it writes.
79
+ gemma_model_id: str = "google/embeddinggemma-2"
80
+ #: Repository commit the local model loads, pinned so a later upload under
81
+ #: the same name cannot change the vectors a store already holds.
82
+ gemma_revision: str = "914f7f89142e33e77833254d9c9b90c3cef7303b"
83
+ #: Where the local model runs; ``auto`` takes CUDA, then Apple MPS, then CPU.
84
+ gemma_device: Literal["auto", "cpu", "cuda", "mps"] = "auto"
85
+ #: Texts per local forward pass. Peak memory grows with it times the
86
+ #: longest text in the batch.
87
+ gemma_batch_size: int = Field(default=32, ge=1)
88
+ #: Local LanceDB dataset URI. ``None`` (the default) resolves per corpus
89
+ #: and per provider via :meth:`resolve_lance_uri` — one vector store per
90
+ #: corpus, so re-pointing the source root never mixes two corpora's
91
+ #: vectors (the corpus-scoping rule).
92
+ lance_uri: Path | None = None
93
+ #: Distance metric for the IVF_HNSW_SQ index.
94
+ hnsw_metric: Literal["cosine", "l2", "dot"] = "cosine"
95
+
96
+ @model_validator(mode="after")
97
+ def _width_fits_provider(self) -> Self:
98
+ """Refuse a width the selected provider cannot emit."""
99
+ allowed = PROVIDER_DIMENSIONS[self.embed_provider]
100
+ if self.output_dimension is not None and self.output_dimension not in allowed:
101
+ msg = (
102
+ f"ATIF_SQL_OUTPUT_DIMENSION={self.output_dimension} is not a width the "
103
+ f"{self.embed_provider} provider emits; use one of {list(allowed)} "
104
+ f"or leave it unset for {DEFAULT_DIMENSION[self.embed_provider]}"
105
+ )
106
+ raise ValueError(msg)
107
+ return self
108
+
109
+ @property
110
+ def embedding_dim(self) -> int:
111
+ """The vector width the selected provider writes and queries."""
112
+ if self.output_dimension is None:
113
+ return DEFAULT_DIMENSION[self.embed_provider]
114
+ return int(self.output_dimension)
115
+
116
+ @property
117
+ def active_model_id(self) -> str:
118
+ """The model identity the selected provider stamps on every row."""
119
+ if self.embed_provider == "gemma":
120
+ return self.gemma_model_id
121
+ return self.embed_model_id
122
+
123
+ @property
124
+ def active_batch_size(self) -> int:
125
+ """Texts per provider call: one Bedrock request, or one local forward pass."""
126
+ return self.gemma_batch_size if self.embed_provider == "gemma" else self.batch_size
127
+
128
+ @property
129
+ def active_concurrency(self) -> int:
130
+ """Batches in flight at once; the local model runs one at a time."""
131
+ return 1 if self.embed_provider == "gemma" else self.embed_concurrency
132
+
133
+ def resolve_lance_uri(self, corpus_root: Path) -> Path:
134
+ """Effective Lance dataset directory for ``corpus_root``."""
135
+ if self.lance_uri is not None:
136
+ return self.lance_uri
137
+ return corpus_root / STORE_DIRNAME[self.embed_provider]
138
+
139
+ def expected_embedding_identity(self) -> tuple[str, int]:
140
+ """Return ``(model_id, dim)`` without building the embedder.
141
+
142
+ Dependency-free identity for the fail-loud store guard: callers that
143
+ only need to BIND the store (e.g. the search path) can enforce the
144
+ provider stamp without importing boto3 or torch.
145
+ """
146
+ return (self.active_model_id, self.embedding_dim)
147
+
148
+
149
+ __all__ = [
150
+ "DEFAULT_DIMENSION",
151
+ "PROVIDER_DIMENSIONS",
152
+ "STORE_DIRNAME",
153
+ "EmbedSettings",
154
+ ]
atif_embed/py.typed ADDED
File without changes
@@ -0,0 +1,14 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """atif-models: model alias registry + structured-output LLM client.
4
+
5
+ Owns CONTRACT-V2.md §Model registry and §Client contract — size aliases
6
+ (small/medium/large) resolve to concrete Bedrock GLOBAL inference-profile
7
+ ids per family (openai default, anthropic escape hatch), and the
8
+ ``LlmStructuredProvider`` port turns (system, prompt, pydantic schema)
9
+ into a validated schema instance. NO other atif-sql package hardcodes a
10
+ model id. Hexagonal: ``domain`` (registry, port, schema transform, usage
11
+ accounting — pure) < ``infrastructure`` (bedrock-runtime adapters, env
12
+ settings). Independence contract: only atif-cli and atif-analytics may
13
+ import this package.
14
+ """
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Pure domain layer for atif-models.
4
+
5
+ The model registry (frozen value objects, no I/O), the
6
+ ``LlmStructuredProvider`` port + error taxonomy + usage accounting, and
7
+ the pydantic → OpenAI strict-mode schema transform. No boto3, no env,
8
+ no clock — infrastructure adapts to these shapes.
9
+ """
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The structured-output provider port + error taxonomy + usage accounting.
4
+
5
+ The seam is narrow on purpose: a system prompt, a user prompt, and a
6
+ pydantic schema in; a validated instance of that schema out. Everything
7
+ provider-specific (model id, region, reasoning effort, the boto3 client,
8
+ the concurrency limiter) is owned by the adapter and fixed at construction.
9
+
10
+ Error taxonomy follows the atif-converter ``DomainError`` pattern:
11
+ ``RefusalError`` is terminal (content filter / model refusal — do not
12
+ retry), ``ProviderUnavailable`` is retryable (the caller's retry queue
13
+ owns the second chance after the adapter's in-process tenacity budget is
14
+ exhausted).
15
+
16
+ Usage accounting: each call yields one frozen :class:`CallUsage`;
17
+ :class:`UsageAccumulator` sums them thread-safely per pipeline. A
18
+ ``threading.Lock`` (not an ``anyio.CapacityLimiter``) guards the counters
19
+ because adapters dispatch blocking ``invoke_model`` calls via
20
+ ``anyio.to_thread`` — two worker threads can land in
21
+ :meth:`~UsageAccumulator.add` concurrently, and a lock is the right
22
+ primitive for a critical section.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import threading
28
+ from dataclasses import dataclass
29
+ from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable
30
+
31
+ if TYPE_CHECKING:
32
+ from pydantic import BaseModel
33
+
34
+ #: A structured-output schema, bound to ``BaseModel`` so the adapter can
35
+ #: both derive the wire schema and ``model_validate`` the response.
36
+ SchemaT = TypeVar("SchemaT", bound="BaseModel")
37
+
38
+
39
+ class DomainError(Exception):
40
+ """Base for every atif-models domain error."""
41
+
42
+
43
+ class RefusalError(DomainError):
44
+ """Terminal: the model refused (content filter / refusal finish reason).
45
+
46
+ Never retried — the input itself is the problem. Callers record the
47
+ unit as refused and move on.
48
+ """
49
+
50
+
51
+ class ProviderUnavailable(DomainError): # noqa: N818 — names a provider state, not an "*Error"
52
+ """Retryable: transport/service failure or an unusable response.
53
+
54
+ Raised after the adapter's in-process tenacity budget is exhausted,
55
+ or immediately for non-retryable-but-recoverable shapes (e.g. a
56
+ ``length`` finish reason — bump ``max_completion_tokens`` or shrink
57
+ the prompt). Callers enqueue the unit on the retry queue.
58
+ """
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class CallUsage:
63
+ """Token accounting for one structured-output call.
64
+
65
+ ``reasoning_tokens`` is the GPT-5.6 ``completion_tokens_details.reasoning_tokens``
66
+ count (a subset of ``output_tokens``, already billed as output);
67
+ ``cached_tokens`` is ``prompt_tokens_details.cached_tokens`` (a subset
68
+ of ``input_tokens`` billed at the cached rate). Both are 0 when the
69
+ provider does not report them.
70
+ """
71
+
72
+ input_tokens: int = 0
73
+ output_tokens: int = 0
74
+ reasoning_tokens: int = 0
75
+ cached_tokens: int = 0
76
+
77
+
78
+ class UsageAccumulator:
79
+ """Thread-safe per-pipeline accumulation of :class:`CallUsage`.
80
+
81
+ One instance per pipeline run; adapters call :meth:`add` after every
82
+ successful response, and the pipeline reads :meth:`summary` at the
83
+ end for the cost-guard log line.
84
+ """
85
+
86
+ def __init__(self) -> None:
87
+ self._lock = threading.Lock()
88
+ self._calls = 0
89
+ self._input_tokens = 0
90
+ self._output_tokens = 0
91
+ self._reasoning_tokens = 0
92
+ self._cached_tokens = 0
93
+
94
+ def add(self, usage: CallUsage) -> None:
95
+ """Fold one call's usage into the totals (safe from any thread)."""
96
+ with self._lock:
97
+ self._calls += 1
98
+ self._input_tokens += usage.input_tokens
99
+ self._output_tokens += usage.output_tokens
100
+ self._reasoning_tokens += usage.reasoning_tokens
101
+ self._cached_tokens += usage.cached_tokens
102
+
103
+ def summary(self) -> dict[str, int]:
104
+ """Snapshot of the totals: calls + the four token counters."""
105
+ with self._lock:
106
+ return {
107
+ "calls": self._calls,
108
+ "input_tokens": self._input_tokens,
109
+ "output_tokens": self._output_tokens,
110
+ "reasoning_tokens": self._reasoning_tokens,
111
+ "cached_tokens": self._cached_tokens,
112
+ }
113
+
114
+
115
+ @runtime_checkable
116
+ class LlmStructuredProvider(Protocol):
117
+ """Port: one structured-output call. One adapter per backend.
118
+
119
+ The seam is deliberately narrow — everything provider-specific is
120
+ fixed at adapter construction.
121
+ """
122
+
123
+ async def classify_structured(
124
+ self, *, system: str, prompt: str, schema: type[SchemaT]
125
+ ) -> SchemaT:
126
+ """Run one structured-output call and return a validated ``schema`` instance.
127
+
128
+ ``system`` goes in as a ``system``-role message; ``prompt`` is the
129
+ per-call user payload. Raises :class:`RefusalError` (terminal) or
130
+ :class:`ProviderUnavailable` (retryable).
131
+ """
132
+ ...
133
+
134
+
135
+ __all__ = [
136
+ "CallUsage",
137
+ "DomainError",
138
+ "LlmStructuredProvider",
139
+ "ProviderUnavailable",
140
+ "RefusalError",
141
+ "SchemaT",
142
+ "UsageAccumulator",
143
+ ]
@@ -0,0 +1,151 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The model alias registry (CONTRACT-V2 §Model registry).
4
+
5
+ Size aliases (small/medium/large) resolve to concrete Bedrock GLOBAL
6
+ inference-profile ids per family. This module is the ONLY place in the
7
+ atif-sql workspace where a model id is written down — every pipeline
8
+ names a (family, size) pair and lets :func:`resolve` pick the id.
9
+
10
+ Pricing (USD per 1M tokens, on-demand; Bedrock charges OpenAI/Anthropic
11
+ list parity, so these track the vendors' published rates):
12
+
13
+ * openai gpt-5.6, post the 2026-07-30 Bedrock price cut: luna $0.20/$1.20,
14
+ terra $2/$12, sol $5/$30.
15
+ * anthropic: haiku-4-5 $1/$5, sonnet-5 $2/$10, opus-5 $5/$25.
16
+
17
+ Pure domain: frozen value objects, no I/O, no env. Env-driven family /
18
+ size selection lives in :mod:`atif_models.infrastructure.settings`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Literal
25
+
26
+ Family = Literal["openai", "anthropic"]
27
+ Size = Literal["small", "medium", "large"]
28
+
29
+ #: GPT-5.6 reasoning levels per the Bedrock GA post; the anthropic entries
30
+ #: reuse the same vocabulary.
31
+ ReasoningEffort = Literal["none", "low", "medium", "high", "xhigh", "max"]
32
+
33
+ DEFAULT_FAMILY: Family = "openai"
34
+ DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"
35
+ DEFAULT_MAX_COMPLETION_TOKENS = 32_000
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ModelSpec:
40
+ """One concrete model behind a (family, size) alias.
41
+
42
+ ``pricing_in`` / ``pricing_out`` are USD per 1M tokens (``None`` when
43
+ unknown — :func:`estimate_cost` tolerates that by returning ``None``).
44
+ ``supports_strict_json`` marks native OpenAI strict structured outputs
45
+ (``response_format.json_schema.strict``); the anthropic entries are
46
+ ``False`` because Bedrock-Anthropic enforces schemas through
47
+ ``output_config``, a different contract.
48
+ """
49
+
50
+ family: Family
51
+ size: Size
52
+ model_id: str
53
+ pricing_in: float | None
54
+ pricing_out: float | None
55
+ max_completion_tokens: int = DEFAULT_MAX_COMPLETION_TOKENS
56
+ reasoning_effort: ReasoningEffort = DEFAULT_REASONING_EFFORT
57
+ supports_strict_json: bool = True
58
+
59
+
60
+ REGISTRY: dict[tuple[Family, Size], ModelSpec] = {
61
+ ("openai", "small"): ModelSpec(
62
+ family="openai",
63
+ size="small",
64
+ model_id="global.openai.gpt-5.6-luna",
65
+ pricing_in=0.20,
66
+ pricing_out=1.20,
67
+ ),
68
+ ("openai", "medium"): ModelSpec(
69
+ family="openai",
70
+ size="medium",
71
+ model_id="global.openai.gpt-5.6-terra",
72
+ pricing_in=2.00,
73
+ pricing_out=12.00,
74
+ ),
75
+ ("openai", "large"): ModelSpec(
76
+ family="openai",
77
+ size="large",
78
+ model_id="global.openai.gpt-5.6-sol",
79
+ pricing_in=5.00,
80
+ pricing_out=30.00,
81
+ ),
82
+ ("anthropic", "small"): ModelSpec(
83
+ family="anthropic",
84
+ size="small",
85
+ model_id="global.anthropic.claude-haiku-4-5",
86
+ pricing_in=1.00,
87
+ pricing_out=5.00,
88
+ supports_strict_json=False,
89
+ ),
90
+ ("anthropic", "medium"): ModelSpec(
91
+ family="anthropic",
92
+ size="medium",
93
+ model_id="global.anthropic.claude-sonnet-5",
94
+ pricing_in=2.00,
95
+ pricing_out=10.00,
96
+ supports_strict_json=False,
97
+ ),
98
+ ("anthropic", "large"): ModelSpec(
99
+ family="anthropic",
100
+ size="large",
101
+ model_id="global.anthropic.claude-opus-5",
102
+ pricing_in=5.00,
103
+ pricing_out=25.00,
104
+ supports_strict_json=False,
105
+ ),
106
+ }
107
+
108
+
109
+ def resolve(size: Size, family: Family = DEFAULT_FAMILY) -> ModelSpec:
110
+ """Return the :class:`ModelSpec` behind a (family, size) alias.
111
+
112
+ Default family is openai: GPT-5.6 supports native strict structured
113
+ outputs, and it is the only family with a provider adapter. The
114
+ anthropic column records model ids and pricing for a future adapter;
115
+ :data:`atif_models.infrastructure.settings.RUNNABLE_FAMILIES` is the
116
+ gate that keeps it from being selected before one exists.
117
+ """
118
+ try:
119
+ return REGISTRY[(family, size)]
120
+ except KeyError: # pragma: no cover — Literal types make this unreachable from typed code
121
+ msg = f"no model registered for family={family!r} size={size!r}"
122
+ raise KeyError(msg) from None
123
+
124
+
125
+ def estimate_cost(spec: ModelSpec, *, input_tokens: int, output_tokens: int) -> float | None:
126
+ """USD estimate for one call (or an accumulated pipeline) on ``spec``.
127
+
128
+ Returns ``None`` when either price is unknown — callers must render
129
+ that as "pricing unavailable", never as $0. Cache-read / reasoning
130
+ discounts are deliberately NOT modeled here: reasoning tokens bill as
131
+ output (already inside ``output_tokens`` for the OpenAI usage shape)
132
+ and cached input still bills at a nonzero rate, so this is an upper
133
+ bound suitable for the dry-run cost guard.
134
+ """
135
+ if spec.pricing_in is None or spec.pricing_out is None:
136
+ return None
137
+ return (input_tokens * spec.pricing_in + output_tokens * spec.pricing_out) / 1_000_000
138
+
139
+
140
+ __all__ = [
141
+ "DEFAULT_FAMILY",
142
+ "DEFAULT_MAX_COMPLETION_TOKENS",
143
+ "DEFAULT_REASONING_EFFORT",
144
+ "REGISTRY",
145
+ "Family",
146
+ "ModelSpec",
147
+ "ReasoningEffort",
148
+ "Size",
149
+ "estimate_cost",
150
+ "resolve",
151
+ ]
@@ -0,0 +1,109 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Pydantic → OpenAI strict-mode JSON Schema transform (CONTRACT-V2 §Client contract).
4
+
5
+ OpenAI's strict structured outputs (``response_format.json_schema.strict``)
6
+ accept a JSON Schema subset with two hard rules this module enforces:
7
+
8
+ * ``additionalProperties: false`` on EVERY object level.
9
+ * EVERY property listed in ``required`` — optionality is expressed by
10
+ making the field's type nullable (``["string", "null"]`` / an ``anyOf``
11
+ null branch), never by omitting it from ``required``.
12
+
13
+ ``$defs``/``$ref`` are KEPT: OpenAI strict accepts them, including a
14
+ ``$defs``-referenced enum, on all three gpt-5.6 sizes. Ref inlining is a
15
+ per-provider wire-format concern, so it belongs in whichever adapter needs
16
+ it rather than here.
17
+
18
+ ``default`` is dropped (it contradicts required-everywhere) and ``title``
19
+ is dropped as noise. Pydantic ``model_validate`` on the response is the
20
+ second gate, so ge/le style constraints still apply even if the model
21
+ ignores them.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ if TYPE_CHECKING:
29
+ from pydantic import BaseModel
30
+
31
+ #: Keys whose value is a mapping of NAME -> subschema (names are user data,
32
+ #: never schema keywords — a field literally named "default" must survive).
33
+ _MAP_OF_SCHEMAS = ("$defs", "definitions", "properties", "patternProperties")
34
+ #: Keys whose value is a list of subschemas.
35
+ _LIST_OF_SCHEMAS = ("anyOf", "oneOf", "allOf", "prefixItems")
36
+ #: Keys whose value is a single subschema.
37
+ _SINGLE_SCHEMA = ("items", "contains", "propertyNames", "not")
38
+
39
+
40
+ def to_openai_strict(schema: type[BaseModel]) -> dict[str, Any]:
41
+ """Return ``schema.model_json_schema()`` transformed to OpenAI strict mode.
42
+
43
+ Raises ``ValueError`` for shapes strict mode cannot represent (an
44
+ object with a schema-valued ``additionalProperties``, i.e. a
45
+ ``dict[str, X]`` field) — better a loud failure at body-build time
46
+ than a Bedrock 400 mid-pipeline.
47
+ """
48
+ return _strictify(schema.model_json_schema())
49
+
50
+
51
+ def _strictify(node: dict[str, Any]) -> dict[str, Any]:
52
+ """Recursively apply the strict-mode rules to one schema node."""
53
+ out: dict[str, Any] = {}
54
+ for key, val in node.items():
55
+ if key in ("title", "default"):
56
+ continue
57
+ if key in _MAP_OF_SCHEMAS and isinstance(val, dict):
58
+ out[key] = {name: _strictify(sub) for name, sub in val.items()}
59
+ elif key in _LIST_OF_SCHEMAS and isinstance(val, list):
60
+ out[key] = [_strictify(sub) if isinstance(sub, dict) else sub for sub in val]
61
+ elif key in _SINGLE_SCHEMA and isinstance(val, dict):
62
+ out[key] = _strictify(val)
63
+ elif key == "additionalProperties" and isinstance(val, dict):
64
+ msg = (
65
+ "OpenAI strict mode cannot represent open mappings "
66
+ "(schema-valued additionalProperties, e.g. a dict[str, X] field); "
67
+ "model the keys explicitly or use a list of key/value pairs"
68
+ )
69
+ raise ValueError(msg)
70
+ else:
71
+ out[key] = val
72
+ if out.get("type") == "object" or "properties" in out:
73
+ props: dict[str, Any] = out.get("properties") or {}
74
+ already_required = set(out.get("required") or [])
75
+ for name, sub in props.items():
76
+ if name not in already_required:
77
+ props[name] = _nullable(sub)
78
+ if props:
79
+ out["required"] = list(props)
80
+ out["additionalProperties"] = False
81
+ return out
82
+
83
+
84
+ def _nullable(sub: dict[str, Any]) -> dict[str, Any]:
85
+ """Rewrite one property subschema so ``null`` is an accepted value."""
86
+ if "$ref" in sub:
87
+ rest = {k: v for k, v in sub.items() if k != "$ref"}
88
+ return {**rest, "anyOf": [{"$ref": sub["$ref"]}, {"type": "null"}]}
89
+ if "anyOf" in sub:
90
+ branches: list[Any] = sub["anyOf"]
91
+ if any(isinstance(b, dict) and b.get("type") == "null" for b in branches):
92
+ return sub
93
+ return {**sub, "anyOf": [*branches, {"type": "null"}]}
94
+ type_val = sub.get("type")
95
+ if isinstance(type_val, str):
96
+ new = {**sub, "type": [type_val, "null"]}
97
+ elif isinstance(type_val, list):
98
+ new = sub if "null" in type_val else {**sub, "type": [*type_val, "null"]}
99
+ else:
100
+ # No type and no ref/anyOf (e.g. a bare enum or empty schema):
101
+ # wrap rather than guess.
102
+ return {"anyOf": [sub, {"type": "null"}]}
103
+ enum_val = new.get("enum")
104
+ if isinstance(enum_val, list) and None not in enum_val:
105
+ new = {**new, "enum": [*enum_val, None]}
106
+ return new
107
+
108
+
109
+ __all__ = ["to_openai_strict"]
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Infrastructure layer for atif-models.
4
+
5
+ bedrock-runtime adapters (OpenAI chat-completions default, Anthropic
6
+ ``output_config`` experimental stub) and the ``ATIF_SQL_``-prefixed env
7
+ settings. Everything here imports DOWN into ``domain`` only.
8
+ """