OpenAI API Integration
Prerequisites
Load myai's engineering-principles first. This skill is a language-agnostic extension that covers only how to talk to an OpenAI-compatible LLM API: client choice, request shaping, configuration, structured output, resilient parsing, and retries. Examples are in Python with TypeScript references; the rules apply in any language with an official OpenAI SDK.
The OpenAI API is a weakly-typed dynamic boundary: it accepts untyped request dicts and returns untyped JSON. Treat it like a network boundary, not a type system — validate everything you consume and type everything you build on top.
Key Principles
- Use the official OpenAI SDK (
openaiin Python and TypeScript, and in many other languages). Never hand-roll the HTTP calls — the SDK handles auth headers, timeouts, connection pooling, and error taxonomy. - Use
chat.completions(Chat Completions format). It is the most compatible surface across OpenAI-compatible providers (OpenAI, OpenRouter, Ollama, ...), so the same code works against any of them viabase_url. - Everything about the endpoint is configurable: base URL, model, reasoning effort, and API key must all be overridable through environment variables, config files, or app settings. The exact mechanism is application-specific.
- Modern models need very few knobs. Do not send
temperatureormax_tokensby default. The only request parameters you normally touch aremodel,messages,response_format, and optionallyreasoning_effort.
Defaults
| Concern | Default |
|---|---|
| Client | official openai SDK |
| Protocol | chat.completions |
| Temperature | not sent |
max_tokens |
not sent (only for genuinely bounded outputs) |
reasoning_effort |
free-form passthrough, omitted when unset |
| Prompt | separate template file, single user message (one-shot) |
| Structured output | response_format json_schema with strict: true, validated on response |
| Retries | manual loop, 3 attempts, exponential backoff, covers network + validation failures |
Configuration and Credentials
base_url, model, reasoning_effort, and the API key come from env vars / config / app settings (mechanism is application-specific). Provide sensible defaults for base_url (OpenAI, or your provider) and model, and fail fast with a clear error when the key is missing.
client = openai.AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL });
Never log the API key. Keep it only inside the SDK client constructor; build log payloads from your typed request objects, which carry no credentials.
Request Shaping
- No
temperature. It is no longer relevant for basic cases with modern reasoning models — some ignore it, others behave unexpectedly. Omit it entirely. - No
max_tokensby default. It is rarely needed and silently truncates long outputs. Use it only when the output is genuinely bounded (e.g. a captcha answer needs ~20 tokens); otherwise you get cut-off JSON and text. reasoning_effortis a free-form passthrough (str | None). Allowed values are provider-dependent (OpenAI:low/medium/high; Gemini-compatible:true/false), so accept any string and forward it verbatim; omit the parameter entirely when unset. The SDK types it as a closed literal — that type is wrong for multi-provider use, so treat it as a string passthrough.
completion = await client.chat.completions.create(
model=request.model,
messages=[{"role": "user", "content": prompt}],
reasoning_effort=request.reasoning_effort, # None omits the parameter
)
Prompts
- One-shot / transient requests: send everything in a single
usermessage. Nosystemmessage, no wrapping. This avoids artificial split of a unifromed task into two prompts, and works fine with modern models. - Use a
systemprompt only for chats / multi-turn conversations, where a persistent role is meaningful. - Keep prompts in separate template files, not code strings (Jinja2 in Python, any template engine in TS). The template is the source of truth for the variable list — render with strict undefined handling so a missing variable fails fast instead of silently producing empty text.
# prompts/score.j2 — a file, not a string literal
body = template.render(resume_formatted=resume, vacancy_formatted=vacancy)
Structured Output
When you need a structured result from the model, use Structured Outputs via response_format and always validate the schema on the response. Do not regex-extract, and do not trust the raw text.
completion = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "scoring_result",
"schema": {
"type": "object",
"properties": {
"fit_score": {"type": "integer", "minimum": 1, "maximum": 5},
"comment": {"type": "string"},
},
"required": ["fit_score", "comment"],
"additionalProperties": False,
},
"strict": True,
},
},
)
const completion = await client.chat.completions.create({
model,
messages: [{ role: "user", content: prompt }],
response_format: {
type: "json_schema",
json_schema: {
name: "scoring_result",
schema: {
type: "object",
properties: {
fit_score: { type: "integer", minimum: 1, maximum: 5 },
comment: { type: "string" },
},
required: ["fit_score", "comment"],
additionalProperties: false,
},
strict: true,
},
},
});
Rules:
- Schema: require the fields your logic depends on (
required), forbid extras if needed (additionalProperties: false), and encode ranges/enums in the schema (minimum/maximum) so the model produces in-range values. - Validate on the response: decode and check using any schema-validation tooling, even with
strict: true, cause schema enforcement differs between providers. Return a structured error carrying the raw completion text for diagnostics. - Keep freeform text fields unvalidated. A string field like
commentis intentionally not validated.
Healing (optimistic) parsing
Not every endpoint honors response_format — free-tier / non-strict providers may treat it as a hint and return the JSON wrapped in a markdown code fence (```json or plain ```) or in prose. Extract the payload with three layers of tolerance, in order:
- bare JSON object (fast path for real structured-output endpoints);
- a markdown fenced block, whatever the language tag;
- the substring between the first
{and the last}.
Log at DEBUG when extraction had to happen — this is a known, expected behavior, not a warning. Text with no JSON object is returned unchanged so the caller's parse error stays truthful about the raw output.
def extract_json_text(text: str) -> str:
stripped = text.strip()
if stripped.startswith("{") and stripped.endswith("}"):
return text
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].strip().startswith("```") and lines[-1].strip() == "```":
body = "\n".join(lines[1:-1])
if body.strip():
return body
start = text.find("{")
end = text.rfind("}")
if start != -1 and end > start:
return text[start : end + 1]
return text
Retries
Use a manual retry loop with exponential backoff, 3 attempts by default. Do not rely on the SDK's built-in retries (max_retries) — they cover only transient network errors, not validation failures.
The retry loop must cover both failure classes:
- transient network/API failures (exceptions from the SDK call);
- structured-output validation failures — a model that returns invalid JSON (or an out-of-range value) is simply asked again.
Validation therefore lives inside the retry budget. When the budget is exhausted, return the last failure (exception message or parse error) — never swallow it.
_MAX_ATTEMPTS = 3
_BACKOFF_BASE_SECONDS = 1.0
for attempt in range(1, _MAX_ATTEMPTS + 1):
try:
content = await completions(request)
except Exception as exc:
last_failure = f"call failed: {exc}"
else:
parsed = parse(content)
if parsed.is_ok:
return Ok(parsed.unwrap())
last_failure = parsed.unwrap_err()
if attempt < _MAX_ATTEMPTS:
await asyncio.sleep(_BACKOFF_BASE_SECONDS * (2.0 ** (attempt - 1)))
return Err(last_failure)
Boundaries and Typing (Python)
The SDK is an untyped dynamic boundary — do not leak its Any into your business logic.
- Isolate it behind one typed module: typed frozen dataclasses for requests and results; the SDK is touched in exactly one place.
- Protocol for consumers + injectable low-level call seam: the consumer (your stage/handler) depends on a narrow Protocol; the concrete wrapper takes an injectable
CompletionsFnso tests stub the seam and never hit the network. - Client lifecycle: create the client per call and
await client.close()in afinally(releases the httpx connection pool; the seam stays stateless), or keep a single long-lived client in a service. Per-call is simpler for CLI/tool code; long-lived is right for servers.
Error Handling
- Expected failures — network errors, malformed JSON, out-of-range values, provider errors — are typed errors /
Resultvalues. Exceptions are bugs only. Map the SDK'sopenai.OpenAIErrorto app-level typed errors at a single boundary. - Empty completion content is a failure:
completion.choices[0].message.contentbeingNone/empty means the call did not produce usable output — treat it as a failed attempt (retried, then a typed error), never as success with empty text.
content = completion.choices[0].message.content
if content is None:
raise AiBoundaryError("empty completion") # caught -> retried -> Err
Logging (optional)
Not required by default. Add DEBUG-level request/response logging only when the app needs reach-debugging / tracing of AI output problems (e.g. invalid JSON). Pretty-print JSON payloads; keep unparseable output verbatim so the raw text is available for diagnostics. The API key must never appear in a logged payload. By default, OpenAI lib not exposes it.
Multimodal Input
For image input, send a content array with the text prompt first, then the image as a base64 data URL (text-first is a provider convention, e.g. OpenRouter).
content = [
{"type": "text", "text": question},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
]
completion = await client.chat.completions.create(
model=model, messages=[{"role": "user", "content": content}]
)
Common Mistakes
- Hand-rolling HTTP instead of using the official SDK.
- Sending
temperatureormax_tokensout of habit. - Trusting
strict: trueoutput without validating the response. - Putting prompts in code strings instead of template files.
- Using a
systemmessage for a transient one-shot request. - Relying on SDK built-in retries that ignore validation failures.
- Logging request payloads that include the API key.
Related Skills
engineering-principles— Parent skill. Language-agnostic philosophy (error handling, boundaries, fail fast).api-design— Designing the typed surface between your app and the AI client.security-and-hardening— API key handling, secrets, and trusted input boundaries.building-backends— Where the AI client lives in a service/API architecture.