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.
@@ -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"]
@@ -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")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ nl2data_openai