nl2data-openai 0.1.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.
- nl2data_openai/__init__.py +15 -0
- nl2data_openai/client.py +88 -0
- nl2data_openai/config.py +63 -0
- nl2data_openai/live_evaluation.py +286 -0
- nl2data_openai/mapping.py +413 -0
- nl2data_openai/provider.py +261 -0
- nl2data_openai-0.1.0.dist-info/METADATA +160 -0
- nl2data_openai-0.1.0.dist-info/RECORD +10 -0
- nl2data_openai-0.1.0.dist-info/WHEEL +5 -0
- nl2data_openai-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""OpenAI structured-output provider for ``nl2data-core``.
|
|
2
|
+
|
|
3
|
+
An independent optional distribution implementing the provider-neutral
|
|
4
|
+
``ModelProvider`` contract. The OpenAI SDK is never imported at package
|
|
5
|
+
import time; clients are constructed lazily on first generation from
|
|
6
|
+
injected credentials or a client factory, so core imports and capability
|
|
7
|
+
inspection stay fully offline.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .config import OpenAIProviderConfig
|
|
13
|
+
from .provider import OpenAIModelProvider
|
|
14
|
+
|
|
15
|
+
__all__ = ["OpenAIProviderConfig", "OpenAIModelProvider"]
|
nl2data_openai/client.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Lazy optional OpenAI SDK boundary for the provider package.
|
|
2
|
+
|
|
3
|
+
The ``openai`` package is loaded only inside this module through
|
|
4
|
+
:func:`importlib.import_module`, so importing ``nl2data_openai``, the core,
|
|
5
|
+
or the provider never imports the SDK. Client construction happens lazily
|
|
6
|
+
on first generation; no import-time or capability-time network access
|
|
7
|
+
exists. Error predicates duck-type by class name so injected fake clients
|
|
8
|
+
raise structurally identical errors without the SDK installed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib import import_module
|
|
14
|
+
from importlib.util import find_spec
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from nl2data_core.ai.errors import ModelErrorCode, ModelInvocationError
|
|
18
|
+
|
|
19
|
+
from .config import OpenAIProviderConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def driver_available() -> bool:
|
|
23
|
+
"""Whether the optional ``openai`` SDK is installed."""
|
|
24
|
+
return find_spec("openai") is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_openai_client(config: OpenAIProviderConfig, *, api_key: str) -> Any:
|
|
28
|
+
"""Lazily import the SDK and build a bounded ``AsyncOpenAI`` client.
|
|
29
|
+
|
|
30
|
+
Raises a normalized ``PROVIDER_UNAVAILABLE`` error when the SDK is
|
|
31
|
+
missing or the client cannot be constructed; the key and any driver
|
|
32
|
+
exception text never enter the error.
|
|
33
|
+
"""
|
|
34
|
+
if not driver_available():
|
|
35
|
+
raise ModelInvocationError(
|
|
36
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
37
|
+
"the openai SDK is not installed; install the 'nl2data-openai' package",
|
|
38
|
+
details={"cause_type": "ImportError"},
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
openai = import_module("openai")
|
|
42
|
+
kwargs: dict[str, Any] = {"api_key": api_key, "timeout": config.timeout_seconds}
|
|
43
|
+
if config.base_url is not None:
|
|
44
|
+
kwargs["base_url"] = config.base_url
|
|
45
|
+
if config.organization is not None:
|
|
46
|
+
kwargs["organization"] = config.organization
|
|
47
|
+
return openai.AsyncOpenAI(**kwargs)
|
|
48
|
+
except ModelInvocationError:
|
|
49
|
+
raise
|
|
50
|
+
except Exception as error:
|
|
51
|
+
raise ModelInvocationError(
|
|
52
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
53
|
+
"the openai client could not be constructed",
|
|
54
|
+
details={"cause_type": type(error).__name__},
|
|
55
|
+
) from error
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _class_name(error: BaseException) -> str:
|
|
59
|
+
return error.__class__.__name__
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_timeout_error(error: BaseException) -> bool:
|
|
63
|
+
"""SDK or builtin timeout signals (duck-typed by class name)."""
|
|
64
|
+
if isinstance(error, TimeoutError):
|
|
65
|
+
return True
|
|
66
|
+
return _class_name(error) == "APITimeoutError"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_connection_error(error: BaseException) -> bool:
|
|
70
|
+
"""Connection failure signals (duck-typed by class name)."""
|
|
71
|
+
if isinstance(error, ConnectionError):
|
|
72
|
+
return True
|
|
73
|
+
return _class_name(error) in {"APIConnectionError", "APIConnectionPoolTimeoutError"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_rate_limit_error(error: BaseException) -> bool:
|
|
77
|
+
"""Rate-limit signals (duck-typed by class name)."""
|
|
78
|
+
return _class_name(error) == "RateLimitError"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_authentication_error(error: BaseException) -> bool:
|
|
82
|
+
"""Credential-rejection signals (duck-typed by class name)."""
|
|
83
|
+
return _class_name(error) in {"AuthenticationError", "PermissionDeniedError"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_status_error(error: BaseException) -> bool:
|
|
87
|
+
"""Any SDK status error exposing an HTTP status code (duck-typed)."""
|
|
88
|
+
return _class_name(error) == "APIStatusError" or hasattr(error, "status_code")
|
nl2data_openai/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Immutable credential-free configuration for the OpenAI provider.
|
|
2
|
+
|
|
3
|
+
The configuration carries model selection and bounded invocation settings
|
|
4
|
+
only. API keys never enter this model: hosts inject credentials through an
|
|
5
|
+
``api_key_resolver`` callable or a ``client_factory`` at provider
|
|
6
|
+
construction, so keys cannot appear in configuration fingerprints, request
|
|
7
|
+
metadata, workflow state, telemetry, or error records.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from nl2data_core.canonical import strict_sha256_fingerprint
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
16
|
+
|
|
17
|
+
_FINGERPRINT_PATTERN = r"^sha256:[0-9a-f]{64}$"
|
|
18
|
+
_MAX_OUTPUT_TOKENS = 131_072
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OpenAIProviderConfig(BaseModel):
|
|
22
|
+
"""Immutable bounded OpenAI invocation settings.
|
|
23
|
+
|
|
24
|
+
``model_name`` selects the vendor model; all other fields bound the
|
|
25
|
+
invocation. ``base_url`` and ``organization`` are optional host-owned
|
|
26
|
+
endpoint overrides that never appear in normalized errors.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
30
|
+
|
|
31
|
+
model_name: str = Field(min_length=1, max_length=128)
|
|
32
|
+
max_input_chars: int = Field(default=100_000, ge=1_000, le=1_000_000)
|
|
33
|
+
max_output_tokens: int = Field(default=4096, ge=1, le=_MAX_OUTPUT_TOKENS)
|
|
34
|
+
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
|
|
35
|
+
timeout_seconds: float = Field(default=30.0, gt=0.0, le=3600.0)
|
|
36
|
+
base_url: str | None = Field(default=None, max_length=512)
|
|
37
|
+
organization: str | None = Field(default=None, max_length=256)
|
|
38
|
+
merge_developer_into_system: bool = False
|
|
39
|
+
fingerprint: str = Field(default="", pattern=_FINGERPRINT_PATTERN)
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="after")
|
|
42
|
+
def _compute_fingerprint(self) -> OpenAIProviderConfig:
|
|
43
|
+
object.__setattr__(self, "fingerprint", strict_sha256_fingerprint(self.safe_payload()))
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def safe_payload(self) -> dict[str, Any]:
|
|
47
|
+
"""Serializable payload with no credential-bearing fields."""
|
|
48
|
+
return {
|
|
49
|
+
"model_name": self.model_name,
|
|
50
|
+
"max_input_chars": self.max_input_chars,
|
|
51
|
+
"max_output_tokens": self.max_output_tokens,
|
|
52
|
+
"temperature": self.temperature,
|
|
53
|
+
"timeout_seconds": self.timeout_seconds,
|
|
54
|
+
"base_url": self.base_url,
|
|
55
|
+
"organization": self.organization,
|
|
56
|
+
"merge_developer_into_system": self.merge_developer_into_system,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def safe_dump(self) -> dict[str, Any]:
|
|
60
|
+
"""Diagnostics-safe serialization; contains no secrets."""
|
|
61
|
+
payload = self.safe_payload()
|
|
62
|
+
payload["fingerprint"] = self.fingerprint
|
|
63
|
+
return payload
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Opt-in live OpenAI evaluation profile for the AI evaluation foundation.
|
|
2
|
+
|
|
3
|
+
Runs a deterministic :class:`AIEvaluationDataset` through the real
|
|
4
|
+
:class:`IntentResolver` with :class:`OpenAIModelProvider` and classifies
|
|
5
|
+
every case as ``verified``, ``unavailable``, or ``skipped``. The profile
|
|
6
|
+
is opt-in: without injected credentials/factory (or the
|
|
7
|
+
``OPENAI_API_KEY`` environment variable) every case is ``skipped``, so
|
|
8
|
+
default CI needs no credentials and makes no network access. Evidence
|
|
9
|
+
carries only protected fingerprints and normalized codes - never keys,
|
|
10
|
+
raw prompts, raw provider payloads, or native clients.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
from nl2data_core.ai.config import ModelConfig
|
|
22
|
+
from nl2data_core.ai.context import SemanticReference, assemble_model_context
|
|
23
|
+
from nl2data_core.ai.errors import (
|
|
24
|
+
ModelErrorCode,
|
|
25
|
+
ModelErrorRecord,
|
|
26
|
+
ModelInvocationError,
|
|
27
|
+
normalize_model_error,
|
|
28
|
+
)
|
|
29
|
+
from nl2data_core.ai.evaluation.models import (
|
|
30
|
+
AIEvaluationDataset,
|
|
31
|
+
AIProtectedEvidence,
|
|
32
|
+
LiveAICaseResult,
|
|
33
|
+
LiveAIEvaluationReport,
|
|
34
|
+
LiveAvailability,
|
|
35
|
+
)
|
|
36
|
+
from nl2data_core.ai.instructions import assemble_instruction_bundle
|
|
37
|
+
from nl2data_core.ai.models import (
|
|
38
|
+
ClarificationRequired,
|
|
39
|
+
RejectedIntent,
|
|
40
|
+
ResolvedIntent,
|
|
41
|
+
ResolvedMultiEntityIntent,
|
|
42
|
+
)
|
|
43
|
+
from nl2data_core.ai.resolver import IntentResolver
|
|
44
|
+
from nl2data_core.fixtures.models import FIXED_TIMEZONE, TIME_ANCHOR
|
|
45
|
+
from nl2data_core.planning.validation import AuthorizedView
|
|
46
|
+
|
|
47
|
+
from .config import OpenAIProviderConfig
|
|
48
|
+
from .provider import OpenAIModelProvider
|
|
49
|
+
|
|
50
|
+
#: Rejection codes that mean the provider call itself failed (auth,
|
|
51
|
+
#: unreachable, timeout, rate limit, or unknown). Content-level rejections
|
|
52
|
+
#: (malformed/unsafe/bounds) prove the service was reachable and therefore
|
|
53
|
+
#: count as verified evidence, never as an unavailable provider.
|
|
54
|
+
_UNREACHABLE_CODES = frozenset(
|
|
55
|
+
{
|
|
56
|
+
ModelErrorCode.MODEL_TIMEOUT,
|
|
57
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
58
|
+
ModelErrorCode.RETRY_EXHAUSTED,
|
|
59
|
+
ModelErrorCode.UNKNOWN_MODEL_ERROR,
|
|
60
|
+
ModelErrorCode.INVALID_REQUEST,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def run_live_openai_evaluation(
|
|
66
|
+
*,
|
|
67
|
+
dataset: AIEvaluationDataset,
|
|
68
|
+
run_id: str,
|
|
69
|
+
view: AuthorizedView,
|
|
70
|
+
provider_config: OpenAIProviderConfig,
|
|
71
|
+
semantic_references: Mapping[str, SemanticReference] | None = None,
|
|
72
|
+
model_config: ModelConfig | None = None,
|
|
73
|
+
api_key_resolver: Callable[[], str] | None = None,
|
|
74
|
+
client_factory: Callable[[], Any] | None = None,
|
|
75
|
+
min_confidence: float = 0.6,
|
|
76
|
+
time_anchor: datetime = TIME_ANCHOR,
|
|
77
|
+
timezone: str = FIXED_TIMEZONE,
|
|
78
|
+
progress_callback: Callable[[str, str, LiveAICaseResult], None] | None = None,
|
|
79
|
+
) -> LiveAIEvaluationReport:
|
|
80
|
+
"""Run the dataset against the live OpenAI provider and classify cases.
|
|
81
|
+
|
|
82
|
+
Cases are ``skipped`` when the profile is not configured (no injected
|
|
83
|
+
credentials/factory and no ``OPENAI_API_KEY``); ``unavailable`` when a
|
|
84
|
+
provider call fails (credentials rejected, unreachable, timeout, or
|
|
85
|
+
rate-limited) or a rejection carries a provider-level error code;
|
|
86
|
+
``verified`` when the provider call completed and protected evidence
|
|
87
|
+
was collected - including cases whose output was rejected by the
|
|
88
|
+
resolver gates, since those prove the live service behaved as
|
|
89
|
+
configured.
|
|
90
|
+
"""
|
|
91
|
+
references = dict(semantic_references or {})
|
|
92
|
+
resolver_config = model_config or ModelConfig()
|
|
93
|
+
if not _credentials_available(api_key_resolver, client_factory):
|
|
94
|
+
return _skipped_report(
|
|
95
|
+
dataset, run_id, provider_config, time_anchor, timezone
|
|
96
|
+
)
|
|
97
|
+
provider = OpenAIModelProvider(
|
|
98
|
+
provider_config,
|
|
99
|
+
api_key_resolver=api_key_resolver,
|
|
100
|
+
client_factory=client_factory,
|
|
101
|
+
)
|
|
102
|
+
results: list[LiveAICaseResult] = []
|
|
103
|
+
try:
|
|
104
|
+
for case in dataset.cases:
|
|
105
|
+
if progress_callback is not None:
|
|
106
|
+
progress_callback("start", case.case_id, _pending_case(case.case_id))
|
|
107
|
+
result = await _run_case(
|
|
108
|
+
case=case,
|
|
109
|
+
provider=provider,
|
|
110
|
+
view=view,
|
|
111
|
+
references=references,
|
|
112
|
+
resolver_config=resolver_config,
|
|
113
|
+
min_confidence=min_confidence,
|
|
114
|
+
)
|
|
115
|
+
results.append(result)
|
|
116
|
+
if progress_callback is not None:
|
|
117
|
+
progress_callback("complete", case.case_id, result)
|
|
118
|
+
finally:
|
|
119
|
+
await provider.close()
|
|
120
|
+
return LiveAIEvaluationReport(
|
|
121
|
+
dataset_id=dataset.dataset_id,
|
|
122
|
+
run_id=run_id,
|
|
123
|
+
provider_name="openai",
|
|
124
|
+
model_name=provider_config.model_name,
|
|
125
|
+
time_anchor=time_anchor,
|
|
126
|
+
timezone=timezone,
|
|
127
|
+
results=tuple(results),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pending_case(case_id: str) -> LiveAICaseResult:
|
|
132
|
+
"""Create a safe progress placeholder before a case starts."""
|
|
133
|
+
return LiveAICaseResult(case_id=case_id, availability=LiveAvailability.SKIPPED)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _credentials_available(
|
|
137
|
+
api_key_resolver: Callable[[], str] | None,
|
|
138
|
+
client_factory: Callable[[], Any] | None,
|
|
139
|
+
) -> bool:
|
|
140
|
+
if api_key_resolver is not None or client_factory is not None:
|
|
141
|
+
return True
|
|
142
|
+
return bool(os.environ.get("OPENAI_API_KEY"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _skipped_report(
|
|
146
|
+
dataset: AIEvaluationDataset,
|
|
147
|
+
run_id: str,
|
|
148
|
+
provider_config: OpenAIProviderConfig,
|
|
149
|
+
time_anchor: datetime,
|
|
150
|
+
timezone: str,
|
|
151
|
+
) -> LiveAIEvaluationReport:
|
|
152
|
+
results = tuple(
|
|
153
|
+
LiveAICaseResult(
|
|
154
|
+
case_id=case.case_id,
|
|
155
|
+
availability=LiveAvailability.SKIPPED,
|
|
156
|
+
skip_reason="live OpenAI profile is not configured",
|
|
157
|
+
)
|
|
158
|
+
for case in dataset.cases
|
|
159
|
+
)
|
|
160
|
+
return LiveAIEvaluationReport(
|
|
161
|
+
dataset_id=dataset.dataset_id,
|
|
162
|
+
run_id=run_id,
|
|
163
|
+
provider_name="openai",
|
|
164
|
+
model_name=provider_config.model_name,
|
|
165
|
+
time_anchor=time_anchor,
|
|
166
|
+
timezone=timezone,
|
|
167
|
+
results=results,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _run_case(
|
|
172
|
+
*,
|
|
173
|
+
case: Any,
|
|
174
|
+
provider: OpenAIModelProvider,
|
|
175
|
+
view: AuthorizedView,
|
|
176
|
+
references: dict[str, SemanticReference],
|
|
177
|
+
resolver_config: ModelConfig,
|
|
178
|
+
min_confidence: float,
|
|
179
|
+
) -> LiveAICaseResult:
|
|
180
|
+
started = time.perf_counter()
|
|
181
|
+
if case.skip_reason:
|
|
182
|
+
return LiveAICaseResult(
|
|
183
|
+
case_id=case.case_id,
|
|
184
|
+
availability=LiveAvailability.SKIPPED,
|
|
185
|
+
skip_reason=case.skip_reason,
|
|
186
|
+
duration_ms=0,
|
|
187
|
+
)
|
|
188
|
+
calls_before = provider.call_count
|
|
189
|
+
try:
|
|
190
|
+
outcome = await IntentResolver(
|
|
191
|
+
view=view,
|
|
192
|
+
semantic_references=references,
|
|
193
|
+
config=resolver_config,
|
|
194
|
+
min_confidence=min_confidence,
|
|
195
|
+
).resolve(case.request, provider)
|
|
196
|
+
if (
|
|
197
|
+
isinstance(outcome, RejectedIntent)
|
|
198
|
+
and outcome.error.code in _UNREACHABLE_CODES
|
|
199
|
+
):
|
|
200
|
+
return LiveAICaseResult(
|
|
201
|
+
case_id=case.case_id,
|
|
202
|
+
availability=LiveAvailability.UNAVAILABLE,
|
|
203
|
+
error=outcome.error,
|
|
204
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
205
|
+
)
|
|
206
|
+
evidence = _build_evidence(
|
|
207
|
+
case,
|
|
208
|
+
outcome,
|
|
209
|
+
view,
|
|
210
|
+
references,
|
|
211
|
+
resolver_config,
|
|
212
|
+
call_count=provider.call_count - calls_before,
|
|
213
|
+
)
|
|
214
|
+
return LiveAICaseResult(
|
|
215
|
+
case_id=case.case_id,
|
|
216
|
+
availability=LiveAvailability.VERIFIED,
|
|
217
|
+
evidence=evidence,
|
|
218
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
219
|
+
)
|
|
220
|
+
except ModelInvocationError as error:
|
|
221
|
+
return LiveAICaseResult(
|
|
222
|
+
case_id=case.case_id,
|
|
223
|
+
availability=LiveAvailability.UNAVAILABLE,
|
|
224
|
+
error=error.to_record(),
|
|
225
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
226
|
+
)
|
|
227
|
+
except Exception as error:
|
|
228
|
+
return LiveAICaseResult(
|
|
229
|
+
case_id=case.case_id,
|
|
230
|
+
availability=LiveAvailability.UNAVAILABLE,
|
|
231
|
+
error=normalize_model_error(error),
|
|
232
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _build_evidence(
|
|
237
|
+
case: Any,
|
|
238
|
+
outcome: (
|
|
239
|
+
ResolvedIntent
|
|
240
|
+
| ResolvedMultiEntityIntent
|
|
241
|
+
| ClarificationRequired
|
|
242
|
+
| RejectedIntent
|
|
243
|
+
),
|
|
244
|
+
view: AuthorizedView,
|
|
245
|
+
references: dict[str, SemanticReference],
|
|
246
|
+
resolver_config: ModelConfig,
|
|
247
|
+
*,
|
|
248
|
+
call_count: int = 1,
|
|
249
|
+
) -> AIProtectedEvidence:
|
|
250
|
+
context = assemble_model_context(
|
|
251
|
+
request=case.request,
|
|
252
|
+
view=view,
|
|
253
|
+
semantic_references=references,
|
|
254
|
+
max_output_tokens=resolver_config.max_output_tokens,
|
|
255
|
+
)
|
|
256
|
+
instruction = assemble_instruction_bundle(
|
|
257
|
+
request=case.request,
|
|
258
|
+
context=context,
|
|
259
|
+
view=view,
|
|
260
|
+
)
|
|
261
|
+
if isinstance(outcome, (ResolvedIntent, ResolvedMultiEntityIntent)):
|
|
262
|
+
resolution: Literal["resolved", "clarification", "rejected"] = "resolved"
|
|
263
|
+
intent_fingerprint = outcome.intent.fingerprint
|
|
264
|
+
clarification_fingerprint: str | None = None
|
|
265
|
+
error: ModelErrorRecord | None = None
|
|
266
|
+
elif isinstance(outcome, ClarificationRequired):
|
|
267
|
+
resolution = "clarification"
|
|
268
|
+
intent_fingerprint = None
|
|
269
|
+
clarification_fingerprint = outcome.clarification.fingerprint
|
|
270
|
+
error = None
|
|
271
|
+
else:
|
|
272
|
+
resolution = "rejected"
|
|
273
|
+
intent_fingerprint = None
|
|
274
|
+
clarification_fingerprint = None
|
|
275
|
+
error = outcome.error
|
|
276
|
+
return AIProtectedEvidence(
|
|
277
|
+
case_id=case.case_id,
|
|
278
|
+
outcome=resolution,
|
|
279
|
+
intent_fingerprint=intent_fingerprint,
|
|
280
|
+
clarification_fingerprint=clarification_fingerprint,
|
|
281
|
+
error=error,
|
|
282
|
+
call_count=call_count,
|
|
283
|
+
context_fingerprint=context.fingerprint,
|
|
284
|
+
instruction_fingerprint=instruction.fingerprint,
|
|
285
|
+
output_schema_fingerprint=instruction.output_contract.fingerprint,
|
|
286
|
+
)
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""OpenAI request/response mapping for the provider-neutral contract.
|
|
2
|
+
|
|
3
|
+
The validated provider-neutral instruction bundle is mapped onto the
|
|
4
|
+
system/developer message channels; the user prompt always stays a separate
|
|
5
|
+
user message. Structured output requests a strict JSON envelope matching
|
|
6
|
+
the bounded provider response contract, and extraction fails closed on
|
|
7
|
+
refusal, truncation, malformed JSON, schema mismatches, unsafe shapes, and
|
|
8
|
+
output-bound violations - raw SDK payloads never cross the boundary.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from nl2data_core.ai.errors import ModelErrorCode, ModelInvocationError
|
|
19
|
+
from nl2data_core.ai.instructions import ModelInstructionBundle
|
|
20
|
+
from nl2data_core.ai.models import ModelInvocationRequest, ModelResponse, ModelUsage
|
|
21
|
+
from nl2data_core.ai.resolver import scan_unsafe_output
|
|
22
|
+
|
|
23
|
+
from .config import OpenAIProviderConfig
|
|
24
|
+
|
|
25
|
+
#: Top-level envelope keys a structured response may carry.
|
|
26
|
+
_ALLOWED_ENVELOPE_KEYS = frozenset({"intent", "clarification", "alternatives"})
|
|
27
|
+
|
|
28
|
+
#: Bound on nested JSON container sizes (mirrors the core contract).
|
|
29
|
+
_MAX_JSON_KEYS = 128
|
|
30
|
+
|
|
31
|
+
#: Approximate token size used to enforce the output size bound.
|
|
32
|
+
_MAX_CHARS_PER_TOKEN = 4
|
|
33
|
+
_MAX_TOKEN_COUNT = 1_000_000_000
|
|
34
|
+
|
|
35
|
+
#: Vendor response ids are opaque strings; the fallback is bounded.
|
|
36
|
+
_RESPONSE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.]{0,127}$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_messages(
|
|
40
|
+
request: ModelInvocationRequest,
|
|
41
|
+
*,
|
|
42
|
+
merge_developer_into_system: bool = False,
|
|
43
|
+
) -> list[dict[str, str]]:
|
|
44
|
+
"""Map the instruction bundle and prompt to system/developer/user messages.
|
|
45
|
+
|
|
46
|
+
The system channel carries the role and allowed behavior; the developer
|
|
47
|
+
channel carries safety constraints, the output contract, authorized
|
|
48
|
+
context references, and provenance fingerprints. The user prompt is
|
|
49
|
+
never merged into either channel, so user text cannot rewrite system
|
|
50
|
+
instructions through formatting.
|
|
51
|
+
"""
|
|
52
|
+
messages: list[dict[str, str]] = []
|
|
53
|
+
instruction = request.instruction
|
|
54
|
+
if instruction is not None:
|
|
55
|
+
system_message = _system_message(instruction)
|
|
56
|
+
developer_message = _developer_message(instruction)
|
|
57
|
+
if merge_developer_into_system:
|
|
58
|
+
system_message = f"{system_message}\n\n{developer_message}"
|
|
59
|
+
messages.append({"role": "system", "content": system_message})
|
|
60
|
+
else:
|
|
61
|
+
messages.append({"role": "system", "content": system_message})
|
|
62
|
+
messages.append({"role": "developer", "content": developer_message})
|
|
63
|
+
messages.append({"role": "user", "content": request.prompt})
|
|
64
|
+
return messages
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _system_message(instruction: ModelInstructionBundle) -> str:
|
|
68
|
+
sections = [instruction.role.role]
|
|
69
|
+
if instruction.behavior.behavior:
|
|
70
|
+
sections.append(instruction.behavior.behavior)
|
|
71
|
+
return "\n\n".join(sections)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _developer_message(instruction: ModelInstructionBundle) -> str:
|
|
75
|
+
sections: list[str] = []
|
|
76
|
+
constraints = instruction.safety_constraints
|
|
77
|
+
if constraints:
|
|
78
|
+
sections.append(
|
|
79
|
+
"Safety constraints:\n"
|
|
80
|
+
+ "\n".join(
|
|
81
|
+
f"- [{constraint.reason_code}] {constraint.instruction}"
|
|
82
|
+
for constraint in constraints
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
contract = instruction.output_contract
|
|
86
|
+
sections.append(
|
|
87
|
+
"Output contract: "
|
|
88
|
+
f"schema_id={contract.schema_id}; schema_version={contract.schema_version}; "
|
|
89
|
+
f"response_mode={contract.response_mode.value}; fingerprint={contract.fingerprint}"
|
|
90
|
+
)
|
|
91
|
+
references = instruction.context_references
|
|
92
|
+
if references:
|
|
93
|
+
sections.append(
|
|
94
|
+
"Authorized context references:\n"
|
|
95
|
+
+ "\n".join(
|
|
96
|
+
f"- {reference.field_id}: {reference.label}" for reference in references
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
provenance_parts = [
|
|
100
|
+
(name, value)
|
|
101
|
+
for name, value in (
|
|
102
|
+
("view", instruction.provenance.view_fingerprint),
|
|
103
|
+
("model_bundle", instruction.provenance.model_bundle_fingerprint),
|
|
104
|
+
("policy", instruction.provenance.policy_fingerprint),
|
|
105
|
+
("tenant_scope", instruction.provenance.tenant_scope_fingerprint),
|
|
106
|
+
)
|
|
107
|
+
if value is not None
|
|
108
|
+
]
|
|
109
|
+
if provenance_parts:
|
|
110
|
+
sections.append(
|
|
111
|
+
"Provenance fingerprints:\n"
|
|
112
|
+
+ "\n".join(f"- {name}={value}" for name, value in provenance_parts)
|
|
113
|
+
)
|
|
114
|
+
return "\n\n".join(sections)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_request_params(
|
|
118
|
+
request: ModelInvocationRequest, config: OpenAIProviderConfig
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""One bounded structured-output request for ``chat.completions.create``.
|
|
121
|
+
|
|
122
|
+
The request carries the mapped messages, a strict JSON envelope schema,
|
|
123
|
+
and the bounded output-token budget; only the bounded prompt and
|
|
124
|
+
authorized JSON-compatible context of the invocation are sent.
|
|
125
|
+
"""
|
|
126
|
+
temperature = (
|
|
127
|
+
request.temperature if request.temperature is not None else config.temperature
|
|
128
|
+
)
|
|
129
|
+
params: dict[str, Any] = {
|
|
130
|
+
"model": config.model_name,
|
|
131
|
+
"messages": build_messages(
|
|
132
|
+
request,
|
|
133
|
+
merge_developer_into_system=config.merge_developer_into_system,
|
|
134
|
+
),
|
|
135
|
+
"response_format": {
|
|
136
|
+
"type": "json_schema",
|
|
137
|
+
"json_schema": {
|
|
138
|
+
"name": "structured_intent_envelope",
|
|
139
|
+
"strict": True,
|
|
140
|
+
"schema": build_envelope_schema(),
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
"max_completion_tokens": request.max_output_tokens,
|
|
144
|
+
}
|
|
145
|
+
if temperature is not None:
|
|
146
|
+
params["temperature"] = temperature
|
|
147
|
+
return params
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_envelope_schema() -> dict[str, Any]:
|
|
151
|
+
"""The strict JSON schema for the bounded structured-intent envelope.
|
|
152
|
+
|
|
153
|
+
All envelope keys are required-but-nullable so the schema satisfies
|
|
154
|
+
OpenAI strict structured-output constraints while keeping the envelope
|
|
155
|
+
flexible; the resolver performs the authoritative semantic validation
|
|
156
|
+
after extraction.
|
|
157
|
+
"""
|
|
158
|
+
selection_schema = {
|
|
159
|
+
"type": "object",
|
|
160
|
+
"additionalProperties": False,
|
|
161
|
+
"required": ["selection_id", "field_id", "alias", "aggregation"],
|
|
162
|
+
"properties": {
|
|
163
|
+
"selection_id": {"type": "string"},
|
|
164
|
+
"field_id": {"type": "string"},
|
|
165
|
+
"alias": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
|
166
|
+
"aggregation": {"type": "string"},
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
scalar = {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}
|
|
170
|
+
filter_schema = {
|
|
171
|
+
"type": "object",
|
|
172
|
+
"additionalProperties": False,
|
|
173
|
+
"required": ["filter_id", "field_id", "operator", "value"],
|
|
174
|
+
"properties": {
|
|
175
|
+
"filter_id": {"type": "string"},
|
|
176
|
+
"field_id": {"type": "string"},
|
|
177
|
+
"operator": {"type": "string"},
|
|
178
|
+
"value": {"anyOf": [{"type": "array", "items": scalar}, scalar, {"type": "null"}]},
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
ordering_schema = {
|
|
182
|
+
"type": "object",
|
|
183
|
+
"additionalProperties": False,
|
|
184
|
+
"required": ["ordering_id", "field_id", "direction"],
|
|
185
|
+
"properties": {
|
|
186
|
+
"ordering_id": {"type": "string"},
|
|
187
|
+
"field_id": {"type": "string"},
|
|
188
|
+
"direction": {"type": "string"},
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
option_schema = {
|
|
192
|
+
"type": "object",
|
|
193
|
+
"additionalProperties": False,
|
|
194
|
+
"required": ["option_id", "label", "detail"],
|
|
195
|
+
"properties": {
|
|
196
|
+
"option_id": {"type": "string"},
|
|
197
|
+
"label": {"type": "string"},
|
|
198
|
+
"detail": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
intent_schema = {
|
|
202
|
+
"type": "object",
|
|
203
|
+
"additionalProperties": False,
|
|
204
|
+
"required": [
|
|
205
|
+
"source_id",
|
|
206
|
+
"root_entity_id",
|
|
207
|
+
"selections",
|
|
208
|
+
"filters",
|
|
209
|
+
"orderings",
|
|
210
|
+
"limit",
|
|
211
|
+
"confidence",
|
|
212
|
+
],
|
|
213
|
+
"properties": {
|
|
214
|
+
"source_id": {"type": "string"},
|
|
215
|
+
"root_entity_id": {"type": "string"},
|
|
216
|
+
"selections": {"type": "array", "items": selection_schema},
|
|
217
|
+
"filters": {"type": "array", "items": filter_schema},
|
|
218
|
+
"orderings": {"type": "array", "items": ordering_schema},
|
|
219
|
+
"limit": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
|
|
220
|
+
"confidence": {"type": "number"},
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
clarification_schema = {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"additionalProperties": False,
|
|
226
|
+
"required": ["question", "options"],
|
|
227
|
+
"properties": {
|
|
228
|
+
"question": {"type": "string"},
|
|
229
|
+
"options": {"type": "array", "items": option_schema},
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
"type": "object",
|
|
234
|
+
"additionalProperties": False,
|
|
235
|
+
"required": ["intent", "clarification", "alternatives"],
|
|
236
|
+
"properties": {
|
|
237
|
+
"intent": {"anyOf": [intent_schema, {"type": "null"}]},
|
|
238
|
+
"clarification": {"anyOf": [clarification_schema, {"type": "null"}]},
|
|
239
|
+
"alternatives": {
|
|
240
|
+
"anyOf": [{"type": "array", "items": option_schema}, {"type": "null"}]
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def extract_response(
|
|
247
|
+
response: Any, request: ModelInvocationRequest
|
|
248
|
+
) -> ModelResponse:
|
|
249
|
+
"""Normalize one SDK response into the core structured envelope.
|
|
250
|
+
|
|
251
|
+
Fails closed on refusal, truncation, content-filter rejection, missing
|
|
252
|
+
content, malformed JSON, non-object output, unsupported envelope keys,
|
|
253
|
+
non-JSON-compatible values, and output-bound violations. The raw SDK
|
|
254
|
+
object and its payload never appear in the returned values or errors.
|
|
255
|
+
|
|
256
|
+
Strict OpenAI structured output requires every schema key on the wire,
|
|
257
|
+
including ``null`` placeholders; the resolver interprets key presence as
|
|
258
|
+
a request (for example ``clarification``), so ``null`` envelope values
|
|
259
|
+
are normalized to absent keys before the core envelope is built.
|
|
260
|
+
"""
|
|
261
|
+
if response is None or not getattr(response, "choices", None):
|
|
262
|
+
raise ModelInvocationError(
|
|
263
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
264
|
+
"provider returned an empty response",
|
|
265
|
+
details={"request_id": request.request_id},
|
|
266
|
+
)
|
|
267
|
+
choice = response.choices[0]
|
|
268
|
+
finish_reason = getattr(choice, "finish_reason", None)
|
|
269
|
+
if finish_reason == "length":
|
|
270
|
+
raise ModelInvocationError(
|
|
271
|
+
ModelErrorCode.OUTPUT_LIMIT_EXCEEDED,
|
|
272
|
+
"provider output was truncated at the token bound",
|
|
273
|
+
details={"request_id": request.request_id, "finish_reason": "length"},
|
|
274
|
+
)
|
|
275
|
+
if finish_reason == "content_filter":
|
|
276
|
+
raise ModelInvocationError(
|
|
277
|
+
ModelErrorCode.UNSAFE_OUTPUT,
|
|
278
|
+
"provider output was blocked by content filtering",
|
|
279
|
+
details={"request_id": request.request_id, "finish_reason": "content_filter"},
|
|
280
|
+
)
|
|
281
|
+
message = getattr(choice, "message", None)
|
|
282
|
+
if message is None:
|
|
283
|
+
raise ModelInvocationError(
|
|
284
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
285
|
+
"provider response is missing its message content",
|
|
286
|
+
details={"request_id": request.request_id},
|
|
287
|
+
)
|
|
288
|
+
refusal = getattr(message, "refusal", None)
|
|
289
|
+
if isinstance(refusal, str) and refusal.strip():
|
|
290
|
+
raise ModelInvocationError(
|
|
291
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
292
|
+
"provider refused the request",
|
|
293
|
+
details={"request_id": request.request_id},
|
|
294
|
+
)
|
|
295
|
+
content = getattr(message, "content", None)
|
|
296
|
+
if not isinstance(content, str) or not content.strip():
|
|
297
|
+
raise ModelInvocationError(
|
|
298
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
299
|
+
"provider returned no structured content",
|
|
300
|
+
details={"request_id": request.request_id},
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
parsed = json.loads(content)
|
|
304
|
+
except (TypeError, ValueError) as error:
|
|
305
|
+
raise ModelInvocationError(
|
|
306
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
307
|
+
"provider returned malformed JSON",
|
|
308
|
+
details={"request_id": request.request_id, "cause_type": type(error).__name__},
|
|
309
|
+
) from error
|
|
310
|
+
if not isinstance(parsed, dict):
|
|
311
|
+
raise ModelInvocationError(
|
|
312
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
313
|
+
"provider output is not a JSON object",
|
|
314
|
+
details={"request_id": request.request_id},
|
|
315
|
+
)
|
|
316
|
+
unsupported = [key for key in parsed if key not in _ALLOWED_ENVELOPE_KEYS]
|
|
317
|
+
if unsupported:
|
|
318
|
+
raise ModelInvocationError(
|
|
319
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
320
|
+
"provider output contains unsupported envelope fields",
|
|
321
|
+
details={
|
|
322
|
+
"request_id": request.request_id,
|
|
323
|
+
"fields": ",".join(sorted(unsupported)[:8]),
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
try:
|
|
327
|
+
_check_json_compatible(parsed, "content")
|
|
328
|
+
except ValueError as error:
|
|
329
|
+
raise ModelInvocationError(
|
|
330
|
+
ModelErrorCode.MALFORMED_RESPONSE,
|
|
331
|
+
"provider output contains non-JSON-compatible values",
|
|
332
|
+
details={"request_id": request.request_id, "cause_type": type(error).__name__},
|
|
333
|
+
) from error
|
|
334
|
+
violation = scan_unsafe_output(parsed)
|
|
335
|
+
if violation is not None:
|
|
336
|
+
raise ModelInvocationError(
|
|
337
|
+
ModelErrorCode.UNSAFE_OUTPUT,
|
|
338
|
+
"provider output contains executable or injected content",
|
|
339
|
+
details={"request_id": request.request_id, "reason": violation},
|
|
340
|
+
)
|
|
341
|
+
max_chars = request.max_output_tokens * _MAX_CHARS_PER_TOKEN
|
|
342
|
+
if len(content) > max_chars:
|
|
343
|
+
raise ModelInvocationError(
|
|
344
|
+
ModelErrorCode.OUTPUT_LIMIT_EXCEEDED,
|
|
345
|
+
"provider output exceeds the configured size bound",
|
|
346
|
+
details={"request_id": request.request_id, "max_chars": str(max_chars)},
|
|
347
|
+
)
|
|
348
|
+
usage = map_usage(getattr(response, "usage", None))
|
|
349
|
+
if usage.completion_tokens > request.max_output_tokens:
|
|
350
|
+
raise ModelInvocationError(
|
|
351
|
+
ModelErrorCode.OUTPUT_LIMIT_EXCEEDED,
|
|
352
|
+
"provider output exceeds the configured token bound",
|
|
353
|
+
details={
|
|
354
|
+
"request_id": request.request_id,
|
|
355
|
+
"max_output_tokens": str(request.max_output_tokens),
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
response_id = getattr(response, "id", None)
|
|
359
|
+
if not isinstance(response_id, str) or not _RESPONSE_ID_PATTERN.match(response_id):
|
|
360
|
+
response_id = f"openai-{uuid.uuid4().hex[:16]}"
|
|
361
|
+
content = {key: value for key, value in parsed.items() if value is not None}
|
|
362
|
+
return ModelResponse(
|
|
363
|
+
response_id=response_id,
|
|
364
|
+
request_id=request.request_id,
|
|
365
|
+
content=content,
|
|
366
|
+
usage=usage,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def map_usage(usage: Any) -> ModelUsage:
|
|
371
|
+
"""Map valid OpenAI usage fields into consistent bounded usage.
|
|
372
|
+
|
|
373
|
+
Missing or invalid fields become zero; a vendor-reported total that is
|
|
374
|
+
inconsistent with prompt + completion is recomputed so the bounded
|
|
375
|
+
``ModelUsage`` invariant (total equals prompt plus completion) holds.
|
|
376
|
+
"""
|
|
377
|
+
prompt = _bounded_token_count(getattr(usage, "prompt_tokens", None))
|
|
378
|
+
completion = _bounded_token_count(getattr(usage, "completion_tokens", None))
|
|
379
|
+
total = _bounded_token_count(getattr(usage, "total_tokens", None))
|
|
380
|
+
if total != prompt + completion:
|
|
381
|
+
total = prompt + completion
|
|
382
|
+
return ModelUsage(
|
|
383
|
+
prompt_tokens=prompt, completion_tokens=completion, total_tokens=total
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _bounded_token_count(value: Any) -> int:
|
|
388
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
389
|
+
return 0
|
|
390
|
+
if value < 0 or value > _MAX_TOKEN_COUNT:
|
|
391
|
+
return 0
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _check_json_compatible(value: Any, path: str) -> None:
|
|
396
|
+
"""Reject anything that cannot cross a JSON wire boundary (bounded)."""
|
|
397
|
+
if isinstance(value, (str, int, float, bool, type(None))):
|
|
398
|
+
return
|
|
399
|
+
if isinstance(value, dict):
|
|
400
|
+
if len(value) > _MAX_JSON_KEYS:
|
|
401
|
+
raise ValueError(f"{path} exceeds the bounded key count {_MAX_JSON_KEYS}")
|
|
402
|
+
for key, item in value.items():
|
|
403
|
+
if not isinstance(key, str):
|
|
404
|
+
raise ValueError(f"{path} contains a non-string key")
|
|
405
|
+
_check_json_compatible(item, f"{path}.{key}")
|
|
406
|
+
return
|
|
407
|
+
if isinstance(value, list):
|
|
408
|
+
if len(value) > _MAX_JSON_KEYS:
|
|
409
|
+
raise ValueError(f"{path} exceeds the bounded item count {_MAX_JSON_KEYS}")
|
|
410
|
+
for index, item in enumerate(value):
|
|
411
|
+
_check_json_compatible(item, f"{path}[{index}]")
|
|
412
|
+
return
|
|
413
|
+
raise ValueError(f"{path} contains a non-JSON-compatible value ({type(value).__name__})")
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""The OpenAI structured-output provider implementing the core contract.
|
|
2
|
+
|
|
3
|
+
``OpenAIModelProvider`` is an independent distribution implementing the
|
|
4
|
+
provider-neutral async :class:`ModelProvider` port. The vendor client is
|
|
5
|
+
built lazily on first generation - never at import, construction, or
|
|
6
|
+
capability inspection - from an injected client factory, an injected
|
|
7
|
+
API-key resolver, or the ``OPENAI_API_KEY`` environment variable.
|
|
8
|
+
Credentials are consumed only during client construction and never enter
|
|
9
|
+
core models, request metadata, workflow state, telemetry, or errors. The
|
|
10
|
+
provider performs exactly one bounded vendor request per ``generate()``;
|
|
11
|
+
retry and timeout policy stays with :class:`IntentResolver`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from nl2data_core.ai.errors import ModelErrorCode, ModelInvocationError
|
|
21
|
+
from nl2data_core.ai.instructions import ResponseMode
|
|
22
|
+
from nl2data_core.ai.models import ModelInvocationRequest, ModelResponse
|
|
23
|
+
from nl2data_core.ai.protocol import ModelCapabilities
|
|
24
|
+
from nl2data_core.canonical import canonical_json
|
|
25
|
+
|
|
26
|
+
from .client import (
|
|
27
|
+
build_openai_client,
|
|
28
|
+
is_authentication_error,
|
|
29
|
+
is_connection_error,
|
|
30
|
+
is_rate_limit_error,
|
|
31
|
+
is_status_error,
|
|
32
|
+
is_timeout_error,
|
|
33
|
+
)
|
|
34
|
+
from .config import OpenAIProviderConfig
|
|
35
|
+
from .mapping import build_messages, build_request_params, extract_response
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OpenAIModelProvider:
|
|
39
|
+
"""OpenAI structured-output provider satisfying :class:`ModelProvider`.
|
|
40
|
+
|
|
41
|
+
Credentials are host-injected through ``api_key_resolver`` (a zero- or
|
|
42
|
+
one-argument callable returning the key) or ``client_factory`` (a
|
|
43
|
+
callable returning a ready client - fake or host-managed). Without
|
|
44
|
+
either, the ``OPENAI_API_KEY`` environment variable is used at client
|
|
45
|
+
build time. Keys are never stored on the provider, in requests, or in
|
|
46
|
+
errors.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
config: OpenAIProviderConfig,
|
|
52
|
+
*,
|
|
53
|
+
api_key_resolver: Callable[[], str] | None = None,
|
|
54
|
+
client_factory: Callable[[], Any] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._config = config
|
|
57
|
+
self._api_key_resolver = api_key_resolver
|
|
58
|
+
self._client_factory = client_factory
|
|
59
|
+
self._client: Any = None
|
|
60
|
+
self._call_count = 0
|
|
61
|
+
self._closed = False
|
|
62
|
+
self._capabilities = ModelCapabilities(
|
|
63
|
+
provider_name="openai",
|
|
64
|
+
supports_structured_output=True,
|
|
65
|
+
max_input_chars=config.max_input_chars,
|
|
66
|
+
max_output_tokens=config.max_output_tokens,
|
|
67
|
+
usage_accounting=True,
|
|
68
|
+
instruction_versions=frozenset({1}),
|
|
69
|
+
features=frozenset({"structured_output", "json_schema"}),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def call_count(self) -> int:
|
|
74
|
+
"""Number of vendor requests issued (one per ``generate()``)."""
|
|
75
|
+
return self._call_count
|
|
76
|
+
|
|
77
|
+
def capabilities(self) -> ModelCapabilities:
|
|
78
|
+
"""Configuration-derived capabilities; no network or SDK access."""
|
|
79
|
+
return self._capabilities
|
|
80
|
+
|
|
81
|
+
async def generate(self, request: ModelInvocationRequest) -> ModelResponse:
|
|
82
|
+
"""Generate one bounded structured response.
|
|
83
|
+
|
|
84
|
+
One vendor request per call; failures are raised as normalized
|
|
85
|
+
:class:`ModelInvocationError` values. Retry and timeout policy is
|
|
86
|
+
owned by the resolver, never by this provider.
|
|
87
|
+
"""
|
|
88
|
+
if self._closed:
|
|
89
|
+
raise ModelInvocationError(
|
|
90
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
91
|
+
"provider is closed",
|
|
92
|
+
details={"request_id": request.request_id},
|
|
93
|
+
)
|
|
94
|
+
self._check_bounds(request)
|
|
95
|
+
self._check_instruction(request)
|
|
96
|
+
client = self._get_client()
|
|
97
|
+
params = build_request_params(request, self._config)
|
|
98
|
+
self._call_count += 1
|
|
99
|
+
try:
|
|
100
|
+
response = await client.chat.completions.create(**params)
|
|
101
|
+
except Exception as error:
|
|
102
|
+
raise self._map_error(error, request) from error
|
|
103
|
+
return extract_response(response, request)
|
|
104
|
+
|
|
105
|
+
async def close(self) -> None:
|
|
106
|
+
"""Release the lazily built client exactly once (idempotent).
|
|
107
|
+
|
|
108
|
+
Native client exceptions are swallowed so provider internals never
|
|
109
|
+
leak across the contract boundary.
|
|
110
|
+
"""
|
|
111
|
+
if self._closed:
|
|
112
|
+
return
|
|
113
|
+
self._closed = True
|
|
114
|
+
client = self._client
|
|
115
|
+
self._client = None
|
|
116
|
+
close = getattr(client, "close", None)
|
|
117
|
+
if close is None:
|
|
118
|
+
return
|
|
119
|
+
try:
|
|
120
|
+
await close()
|
|
121
|
+
except Exception:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
def _check_bounds(self, request: ModelInvocationRequest) -> None:
|
|
125
|
+
message_chars = sum(
|
|
126
|
+
len(message["content"])
|
|
127
|
+
for message in build_messages(
|
|
128
|
+
request,
|
|
129
|
+
merge_developer_into_system=self._config.merge_developer_into_system,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
input_chars = message_chars + len(canonical_json(request.context))
|
|
133
|
+
if input_chars > self._config.max_input_chars:
|
|
134
|
+
raise ModelInvocationError(
|
|
135
|
+
ModelErrorCode.INVALID_REQUEST,
|
|
136
|
+
"invocation input exceeds the provider maximum",
|
|
137
|
+
details={
|
|
138
|
+
"input_chars": str(input_chars),
|
|
139
|
+
"max_input_chars": str(self._config.max_input_chars),
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
if request.max_output_tokens > self._config.max_output_tokens:
|
|
143
|
+
raise ModelInvocationError(
|
|
144
|
+
ModelErrorCode.OUTPUT_LIMIT_EXCEEDED,
|
|
145
|
+
"requested output exceeds the provider output bound",
|
|
146
|
+
details={"max_output_tokens": str(self._config.max_output_tokens)},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _check_instruction(self, request: ModelInvocationRequest) -> None:
|
|
150
|
+
instruction = request.instruction
|
|
151
|
+
if instruction is None:
|
|
152
|
+
return
|
|
153
|
+
if instruction.bundle_version not in self._capabilities.instruction_versions:
|
|
154
|
+
raise ModelInvocationError(
|
|
155
|
+
ModelErrorCode.INSTRUCTION_VERSION_INCOMPATIBLE,
|
|
156
|
+
"provider does not support the instruction bundle version",
|
|
157
|
+
details={"instruction_version": str(instruction.bundle_version)},
|
|
158
|
+
)
|
|
159
|
+
if instruction.output_contract.response_mode is not ResponseMode.STRUCTURED:
|
|
160
|
+
raise ModelInvocationError(
|
|
161
|
+
ModelErrorCode.INVALID_REQUEST,
|
|
162
|
+
"provider only supports structured output",
|
|
163
|
+
details={"response_mode": instruction.output_contract.response_mode.value},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _get_client(self) -> Any:
|
|
167
|
+
if self._client is None:
|
|
168
|
+
self._client = self._build_client()
|
|
169
|
+
return self._client
|
|
170
|
+
|
|
171
|
+
def _build_client(self) -> Any:
|
|
172
|
+
if self._client_factory is not None:
|
|
173
|
+
client = self._client_factory()
|
|
174
|
+
if client is None:
|
|
175
|
+
raise ModelInvocationError(
|
|
176
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
177
|
+
"the injected client factory returned no client",
|
|
178
|
+
details={"cause_type": "ClientFactoryError"},
|
|
179
|
+
)
|
|
180
|
+
return client
|
|
181
|
+
api_key: str | None = None
|
|
182
|
+
if self._api_key_resolver is not None:
|
|
183
|
+
api_key = self._api_key_resolver()
|
|
184
|
+
if not api_key:
|
|
185
|
+
api_key = os.environ.get("OPENAI_API_KEY") or None
|
|
186
|
+
if api_key is None:
|
|
187
|
+
raise ModelInvocationError(
|
|
188
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
189
|
+
"no OpenAI credentials are configured",
|
|
190
|
+
details={"cause_type": "MissingCredentials"},
|
|
191
|
+
)
|
|
192
|
+
return build_openai_client(self._config, api_key=api_key)
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _map_error(
|
|
196
|
+
error: BaseException, request: ModelInvocationRequest
|
|
197
|
+
) -> ModelInvocationError:
|
|
198
|
+
"""Map SDK failures to the existing safe error taxonomy.
|
|
199
|
+
|
|
200
|
+
Authentication and configuration failures are non-retryable;
|
|
201
|
+
timeout, connection, and rate-limit failures are retryable
|
|
202
|
+
availability errors; request/schema failures are non-retryable
|
|
203
|
+
request errors. Vendor exception text, endpoints, and credentials
|
|
204
|
+
never enter the mapped error.
|
|
205
|
+
"""
|
|
206
|
+
request_id = request.request_id
|
|
207
|
+
if is_timeout_error(error):
|
|
208
|
+
return ModelInvocationError(
|
|
209
|
+
ModelErrorCode.MODEL_TIMEOUT,
|
|
210
|
+
"model call timed out",
|
|
211
|
+
details={"request_id": request_id, "cause_type": type(error).__name__},
|
|
212
|
+
cause=error,
|
|
213
|
+
)
|
|
214
|
+
if is_rate_limit_error(error):
|
|
215
|
+
return ModelInvocationError(
|
|
216
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
217
|
+
"provider rate limit exceeded",
|
|
218
|
+
details={"request_id": request_id, "cause_type": type(error).__name__},
|
|
219
|
+
cause=error,
|
|
220
|
+
)
|
|
221
|
+
if is_connection_error(error):
|
|
222
|
+
return ModelInvocationError(
|
|
223
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
224
|
+
"provider is unreachable",
|
|
225
|
+
details={"request_id": request_id, "cause_type": type(error).__name__},
|
|
226
|
+
cause=error,
|
|
227
|
+
)
|
|
228
|
+
if is_authentication_error(error):
|
|
229
|
+
return ModelInvocationError(
|
|
230
|
+
ModelErrorCode.INVALID_REQUEST,
|
|
231
|
+
"provider rejected the request credentials",
|
|
232
|
+
details={"request_id": request_id, "cause_type": type(error).__name__},
|
|
233
|
+
cause=error,
|
|
234
|
+
)
|
|
235
|
+
if is_status_error(error):
|
|
236
|
+
status = getattr(error, "status_code", None)
|
|
237
|
+
error_details = {
|
|
238
|
+
"request_id": request_id,
|
|
239
|
+
"cause_type": type(error).__name__,
|
|
240
|
+
}
|
|
241
|
+
if isinstance(status, int):
|
|
242
|
+
error_details["status_code"] = str(status)
|
|
243
|
+
if isinstance(status, int) and (status >= 500 or status == 429):
|
|
244
|
+
return ModelInvocationError(
|
|
245
|
+
ModelErrorCode.PROVIDER_UNAVAILABLE,
|
|
246
|
+
"provider service error",
|
|
247
|
+
details=error_details,
|
|
248
|
+
cause=error,
|
|
249
|
+
)
|
|
250
|
+
return ModelInvocationError(
|
|
251
|
+
ModelErrorCode.INVALID_REQUEST,
|
|
252
|
+
"provider rejected the request",
|
|
253
|
+
details=error_details,
|
|
254
|
+
cause=error,
|
|
255
|
+
)
|
|
256
|
+
return ModelInvocationError(
|
|
257
|
+
ModelErrorCode.UNKNOWN_MODEL_ERROR,
|
|
258
|
+
"unexpected provider error",
|
|
259
|
+
details={"request_id": request_id, "cause_type": type(error).__name__},
|
|
260
|
+
cause=error,
|
|
261
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nl2data-openai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenAI structured-output provider for the nl2data-core model provider boundary.
|
|
5
|
+
Author: NL2Data Contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: nl2data,openai,structured output,model provider
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: nl2data-core>=0.1.0
|
|
18
|
+
Requires-Dist: openai<3,>=1.40
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# nl2data-openai
|
|
26
|
+
|
|
27
|
+
An optional OpenAI structured-output provider for
|
|
28
|
+
[nl2data-core](https://github.com/emmansun/nl2data-core). It implements the
|
|
29
|
+
provider-neutral asynchronous `ModelProvider` contract: bounded
|
|
30
|
+
`ModelInvocationRequest` in, typed `ModelResponse`/normalized
|
|
31
|
+
`ModelInvocationError` out, with the OpenAI SDK isolated to this package.
|
|
32
|
+
|
|
33
|
+
The core import boundary never loads the OpenAI SDK; this package imports
|
|
34
|
+
it **lazily** at client build time — never at import, construction, or
|
|
35
|
+
capability inspection.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install nl2data-openai
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.11+, `nl2data-core>=0.1.0`, and `openai>=1.40,<3`.
|
|
44
|
+
|
|
45
|
+
From a source checkout:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e ".[dev]" # from the repository root (core)
|
|
49
|
+
pip install -e packages/nl2data-openai # this package (editable)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Public surface
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from nl2data_openai import OpenAIProviderConfig, OpenAIModelProvider
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `OpenAIProviderConfig` — vendor `model_name` plus bounded invocation
|
|
59
|
+
settings: `max_input_chars`, `max_output_tokens`, `temperature`,
|
|
60
|
+
`timeout_seconds`, optional `base_url` and `organization`. Capabilities are derived from this
|
|
61
|
+
configuration **without any network call**.
|
|
62
|
+
- `OpenAIModelProvider` — the `ModelProvider` port implementation.
|
|
63
|
+
`close()` is idempotent and never leaks native clients or exceptions.
|
|
64
|
+
|
|
65
|
+
## Credential injection
|
|
66
|
+
|
|
67
|
+
API keys never enter core models, configuration fingerprints, request
|
|
68
|
+
metadata, workflow state, telemetry, or errors. Inject them through one
|
|
69
|
+
of:
|
|
70
|
+
|
|
71
|
+
1. An `api_key_resolver` callable at provider construction, or
|
|
72
|
+
2. A `client_factory` at provider construction, or
|
|
73
|
+
3. The `OPENAI_API_KEY` environment variable — read **only when the
|
|
74
|
+
client is first built**.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import os
|
|
78
|
+
|
|
79
|
+
from nl2data_openai import OpenAIProviderConfig, OpenAIModelProvider
|
|
80
|
+
|
|
81
|
+
provider = OpenAIModelProvider(
|
|
82
|
+
config=OpenAIProviderConfig(model_name="gpt-4o-mini"),
|
|
83
|
+
api_key_resolver=lambda: os.environ["OPENAI_API_KEY"], # host-owned
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
See [Secrets and live testing](../../docs/operations/secrets.md) for the
|
|
88
|
+
full credential-handling contract.
|
|
89
|
+
|
|
90
|
+
## Gateway compatibility
|
|
91
|
+
|
|
92
|
+
Set `base_url` to point at any OpenAI-compatible gateway (OpenAI,
|
|
93
|
+
Azure OpenAI-compatible endpoints, or a self-hosted proxy):
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
OpenAIProviderConfig(
|
|
97
|
+
model_name="deployed-model",
|
|
98
|
+
base_url="https://your-gateway.example/v1", # host-owned endpoint
|
|
99
|
+
timeout_seconds=60,
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`base_url` and `organization` are bounded configuration fields; the
|
|
104
|
+
endpoint itself is a host-owned setting and never part of core
|
|
105
|
+
configuration or evidence.
|
|
106
|
+
|
|
107
|
+
## Model selection and limits
|
|
108
|
+
|
|
109
|
+
`OpenAIProviderConfig` carries `model_name` plus bounded invocation
|
|
110
|
+
settings (`max_input_chars`, `max_output_tokens`, `temperature`,
|
|
111
|
+
`timeout_seconds`, optional `base_url`, `organization`). Provider calls
|
|
112
|
+
are bounded by the resolver's attempt budget; the provider performs
|
|
113
|
+
**exactly one vendor request per `generate()` call** — timeout, retry,
|
|
114
|
+
and attempt-budget policy belong to `IntentResolver`.
|
|
115
|
+
|
|
116
|
+
## Failure classification
|
|
117
|
+
|
|
118
|
+
| Condition | Normalized result |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| Authentication/configuration failure | Non-retryable `INVALID_REQUEST` |
|
|
121
|
+
| Timeout, connection, rate-limit, transient service error | Retryable `MODEL_TIMEOUT` / `PROVIDER_UNAVAILABLE` |
|
|
122
|
+
|
|
123
|
+
## Live testing
|
|
124
|
+
|
|
125
|
+
An opt-in live evaluation profile (`run_live_openai_evaluation` in
|
|
126
|
+
`nl2data_openai.live_evaluation`) runs the deterministic AI dataset
|
|
127
|
+
against the real provider and classifies every case as `verified`,
|
|
128
|
+
`unavailable`, or `skipped`. Without injected credentials/factory or
|
|
129
|
+
`OPENAI_API_KEY`, every case is `skipped` — default CI needs no
|
|
130
|
+
credentials and makes no network access.
|
|
131
|
+
|
|
132
|
+
Local run from the repository root (credentials from the environment
|
|
133
|
+
only — the script never writes them to disk or includes them in output):
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
$env:OPENAI_API_KEY = "..." # host secret injection, never committed
|
|
137
|
+
$env:OPENAI_BASE_URL = "https://api.openai.com/v1"
|
|
138
|
+
$env:OPENAI_MODEL = "gpt-4o-mini"
|
|
139
|
+
$env:OPENAI_TIMEOUT_SECONDS = "60" # optional
|
|
140
|
+
$env:OPENAI_LIVE_CASES = "normal-intent" # optional, comma-separated
|
|
141
|
+
python scripts/run_openai_live.py
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Exit code is 0 only when every selected case is `verified`. Evidence
|
|
145
|
+
carries only protected fingerprints and normalized codes.
|
|
146
|
+
|
|
147
|
+
## Rollback
|
|
148
|
+
|
|
149
|
+
Swap the provider back to the core's deterministic `FakeModelProvider`
|
|
150
|
+
(`nl2data_core.ai.fake` — contributor-only) at composition time to remove
|
|
151
|
+
the SDK dependency and network access while keeping the same resolver,
|
|
152
|
+
governance, and evaluation gates. No runtime migration is involved: the
|
|
153
|
+
provider is a composition input, so rollback is a deployment decision.
|
|
154
|
+
|
|
155
|
+
## More documentation
|
|
156
|
+
|
|
157
|
+
- [Documentation index](../../docs/README.md)
|
|
158
|
+
- [Adding a model provider](../../docs/development/adding-adapter-or-provider.md)
|
|
159
|
+
- [Secrets and live testing](../../docs/operations/secrets.md)
|
|
160
|
+
- [Capabilities and support](../../docs/reference/capabilities.md)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
nl2data_openai/__init__.py,sha256=DhExTiTU9vWrPyYR-wbVl1ZGhM-yon3wVajQG7b4quo,575
|
|
2
|
+
nl2data_openai/client.py,sha256=cXS9qxrTYVsyY4kIPdBXhXfiQlediMvrCL04EHMgg6M,3424
|
|
3
|
+
nl2data_openai/config.py,sha256=CbuLcmRUmg4WZZfreW72-TrevdYHrhmbSVki3DheaTw,2645
|
|
4
|
+
nl2data_openai/live_evaluation.py,sha256=fudITPskP7Bbqt7Tg8-S-uSz6jmdwLe64duCtdCDEFU,10228
|
|
5
|
+
nl2data_openai/mapping.py,sha256=p1XDiFaaI1dnHZE7ECDJWCFNT4PkmJ2Qc82ChOxlE1Y,16856
|
|
6
|
+
nl2data_openai/provider.py,sha256=aKVOITFWJNqApolmmbbq4jGqeJydY1YSVv4LvFv1Mv4,10886
|
|
7
|
+
nl2data_openai-0.1.0.dist-info/METADATA,sha256=HlL6PRTxY3eYbmqXz80LMfo-ngFPbIbSoTn1PxlR-MY,5856
|
|
8
|
+
nl2data_openai-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
nl2data_openai-0.1.0.dist-info/top_level.txt,sha256=Z_Ug_yukpXrNBcJa7iNBXg4wTFk5u8mXRfhfzUQxbrI,15
|
|
10
|
+
nl2data_openai-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nl2data_openai
|