@molecule/api-ai-openai
v1.4.4
Published
<!-- AUTO-GENERATED — DO NOT EDIT THIS FILE. Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json. Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates). To change this document, edit th
Readme
@molecule/api-ai-openai
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
OpenAI (GPT) AI provider for molecule.dev.
Quick Start
// npm install @molecule/api-ai-openai --workspace=api
import { setProvider, requireProvider } from '@molecule/api-ai'
import { provider } from '@molecule/api-ai-openai'
// Named registration — several AI providers can be bonded side by side
// (getProviderByName('openai') targets this one); the FIRST one
// registered also answers requireProvider() (see @molecule/api-ai).
setProvider('openai', provider) // reads OPENAI_API_KEY from the environment
let reply = ''
for await (const event of requireProvider().chat({
messages: [{ role: 'user', content: 'Hello!' }],
})) {
if (event.type === 'text') reply += event.content // or forward the chunk to the client (SSE)
}Type
provider
Installation
npm install @molecule/api-ai-openai @molecule/api-ai @molecule/api-bond @molecule/api-secretsAPI
Interfaces
OpenaiConfig
OpenAI provider configuration.
interface OpenaiConfig {
/** Override the API key (defaults to `process.env.OPENAI_API_KEY`). */
apiKey?: string
/** Default model when callers don't specify one. */
defaultModel?: string
/** Default max output tokens. */
maxTokens?: number
/** Override the API base URL (for proxies / Azure). */
baseUrl?: string
/** Called on each rate-limited/overloaded upstream response, before any retry sleep. */
onRateLimit?: AiRateLimitCallback
/**
* Which OpenAI endpoint to call. Defaults to `'responses'` (`/v1/responses`)
* when the base URL is OpenAI's own API, and to `'chat-completions'`
* (`/v1/chat/completions`) for any other base URL, since OpenAI-compatible
* servers generally implement only chat/completions.
*/
api?: OpenaiApi
}ProcessEnv
Environment variables read by this provider.
interface ProcessEnv {
/** OpenAI API key (required unless `config.apiKey` is passed). */
OPENAI_API_KEY: string
/** Base URL override (for proxies / Azure-compatible gateways). Defaults to `https://api.openai.com`. */
OPENAI_BASE_URL?: string
}Types
OpenaiApi
The OpenAI endpoint a provider instance calls.
type OpenaiApi = 'responses' | 'chat-completions'Classes
OpenaiAIProvider
OpenAI Chat Completions provider implementing the AIProvider interface.
class OpenaiAIProvider implements AIProvider {
readonly name = 'openai'
private apiKey: string
private defaultModel: string
private maxTokens: number
private baseUrl: string
private onRateLimit?: AiRateLimitCallback
private api: OpenaiApi
constructor(config: OpenaiConfig = {}) {
this.apiKey = config.apiKey ?? process.env.OPENAI_API_KEY ?? ''
this.defaultModel = config.defaultModel ?? 'gpt-6-luna'
this.maxTokens = config.maxTokens ?? 4096
this.baseUrl = config.baseUrl ?? process.env.OPENAI_BASE_URL ?? 'https://api.openai.com'
this.onRateLimit = config.onRateLimit
this.api = config.api ?? defaultApiFor(this.baseUrl)
// Fail fast with an actionable local error rather than a cryptic 401 on the
// first request. The default `provider` export constructs lazily on first
// use (see index.ts), so this surfaces the moment the provider is actually
// used, not at bond/module-load time. A key is required only for the
// official endpoint — a custom baseUrl may target a keyless self-hosted /
// local server (Ollama, LM Studio), where the auth header is omitted below.
const usingCustomEndpoint = Boolean(config.baseUrl ?? process.env.OPENAI_BASE_URL)
if (!this.apiKey && !usingCustomEndpoint) {
throw new Error(
'OPENAI_API_KEY is not set. Add it to your environment to use the OpenAI AI provider.',
)
}
}
/**
* Build the request body for `POST /v1/chat/completions`.
*
* @param params - The caller's chat parameters.
* @param model - The resolved model id.
* @param maxTokens - The resolved output-token limit.
* @param useStream - Whether to request an SSE stream.
* @returns The JSON request body.
*/
private buildChatCompletionsBody(
params: ChatParams,
model: string,
maxTokens: number,
useStream: boolean,
): Record<string, unknown> {
const messages = this.formatMessages(params.messages, params.system)
const body: Record<string, unknown> = {
// BYO / provider-native tunables (reasoning_effort, enable_thinking, …).
// Spread FIRST so the structural fields below (model, messages,
// max_completion_tokens, tools, stream) always win and cannot be broken.
...(params.extraBody ?? {}),
// Abuse attribution (see ChatParams.endUserId). OpenAI DEPRECATED `user`
// and replaced it with `safety_identifier` for exactly this purpose —
// their guidance is a hashed, stable per-user string, which is what
// endUserId already is. Sibling OpenAI-compatible bonds keep `user`: it is
// the field they actually implement, verified accepted by deepseek, xai,
// moonshot, alibaba and zhipu against their live APIs.
//
// This bond is also pointed at arbitrary BYO OpenAI-compatible endpoints
// (molecule-dev's custom providers), so the unknown-field question matters
// here: those same five providers all returned 200 when sent an unknown
// `safety_identifier`, i.e. tolerating unknown body params is the norm.
// A BYO endpoint therefore ignores this rather than rejecting it — and BYO
// runs on the user's OWN key, where attribution protects nothing of ours.
...(params.endUserId ? { safety_identifier: params.endUserId } : {}),
model,
messages,
// `max_completion_tokens`, NOT `max_tokens`. Every GPT-5-family model
// hard-rejects the latter — "Unsupported parameter: 'max_tokens' is not
// supported with this model" (400) — and the catalog's OpenAI entries are
// all gpt-5.6-*, so this bond could not call any of its own models. The
// newer name is accepted by the older generations too (verified 200 on
// gpt-4o-mini and gpt-3.5-turbo), so there is no need to branch per model.
max_completion_tokens: maxTokens,
}
if (params.temperature !== undefined) body.temperature = params.temperature
// Reasoning depth: the caller resolves the model's native reasoning_effort
// value (none | low | medium | high | xhigh on GPT-5.4/5.5-family) from the
// model catalog and passes it as `thinking.effort`. Without it, omit the
// param entirely (the model's own default applies — medium on gpt-5.5).
if (params.thinking?.effort) {
body.reasoning_effort = params.thinking.effort
}
const tools = params.tools?.length ? this.formatTools(params.tools) : null
if (tools) {
body.tools = tools
if (params.toolChoice === 'required') {
body.tool_choice = 'required'
} else if (typeof params.toolChoice === 'object' && params.toolChoice?.type === 'tool') {
body.tool_choice = { type: 'function', function: { name: params.toolChoice.name } }
}
}
if (useStream) {
body.stream = true
body.stream_options = { include_usage: true }
}
return body
}
/**
* Send a chat request and yield streamed `ChatEvent`s.
*
* @param params - Chat parameters.
* @yields {ChatEvent}
*/
async *chat(params: ChatParams): AsyncIterable<ChatEvent> {
const model = params.model ?? this.defaultModel
const maxTokens = params.maxTokens ?? this.maxTokens
const useStream = params.stream !== false
const isResponses = this.api === 'responses'
const body = isResponses
? buildResponsesBody(params, model, maxTokens, useStream)
: this.buildChatCompletionsBody(params, model, maxTokens, useStream)
const path = isResponses ? '/v1/responses' : '/v1/chat/completions'
// Per-call correlation (see ChatParams.requestId): a header rather than the
// body's `user`, which is the stable per-account abuse-attribution value.
const requestId = params.requestId ?? randomUUID()
const call: OpenaiCallContext = {
meta: { requestId, endpoint: `${this.baseUrl}${path}` },
endUserId: params.endUserId,
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
// Omitted for a keyless custom endpoint (a self-hosted / local server).
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
'X-Mol-Request-Id': requestId,
}
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
const signal = params.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
const MAX_RETRIES = 3
let response: Response | null = null
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
response = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal,
})
if (response.status === 429 || response.status === 503) {
const willRetry = attempt < MAX_RETRIES
// Retry-After is equally-valid as delta-seconds or an HTTP-date;
// parseInt on the date form yields NaN, degrading the backoff to a
// ~0ms retry against an already rate-limiting API. Guard it.
const retryAfter = response.headers.get('retry-after')
const parsedRetryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : NaN
const hasRetryAfter =
Number.isFinite(parsedRetryAfterSeconds) && parsedRetryAfterSeconds >= 0
// Additive jitter (<500ms): concurrent callers rejected together must
// not sleep identical delays and re-arrive as the same burst.
const delayMs = willRetry
? (hasRetryAfter
? Math.min(parsedRetryAfterSeconds * 1000, 60_000)
: Math.min(1000 * 2 ** attempt, 30_000)) + Math.floor(Math.random() * 500)
: 0
try {
this.onRateLimit?.({
provider: this.name,
model,
status: response.status,
attempt: attempt + 1,
willRetry,
retryInMs: delayMs,
...(hasRetryAfter ? { retryAfterSeconds: parsedRetryAfterSeconds } : {}),
})
} catch (error) {
// Host-app telemetry; its failure must never break the request.
// … truncated — the full declaration is in node_modules/@molecule/api-ai-openai/dist/provider.d.tsFunctions
createProvider(config)
Create an OpenAI AI provider instance.
function createProvider(config?: OpenaiConfig): AIProviderconfig— OpenAI-specific configuration.
Returns: An AIProvider backed by OpenAI's Chat Completions API.
Constants
provider
The provider implementation.
const provider: AIProvideraiOpenaiSecretDefinitions
Secret definitions required by the OpenAI AI bond.
const aiOpenaiSecretDefinitions: SecretDefinition[]Core Interface
Implements @molecule/api-ai interface.
Bond Wiring
Setup function to register this provider with the bond system:
import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-ai-openai'
export function setupAiOpenai(): void {
bond('ai', 'openai', provider)
}Injection Notes
Requirements
Peer dependencies:
@molecule/api-ai^1.0.1@molecule/api-bond^1.0.1@molecule/api-secrets^1.0.1
Environment Variables
OPENAI_API_KEY(required) — OpenAI API key- Setup: Create a secret key on the OpenAI platform (API keys page).
- Get it here: https://platform.openai.com/api-keys
- Example:
sk-proj-...
Runtime Dependencies
@molecule/api-ai@molecule/api-bond@molecule/api-secrets
Config: OPENAI_API_KEY (SERVER-side only) plus an optional default model id/base URL.
Endpoint: on OpenAI's own API (https://api.openai.com, the default) the provider calls
the Responses API (/v1/responses); on any other baseUrl it calls /v1/chat/completions,
the endpoint OpenAI-compatible servers (Ollama, LM Studio, gateways) implement. Override
with api: 'responses' | 'chat-completions'. Current OpenAI reasoning models only accept
function tools together with reasoning on /v1/responses (gpt-6-astra rejects every
tool-carrying request on chat/completions), and only Responses carries OpenAI's server-side
tools (serverTools, e.g. { type: 'web_search', name: 'web_search' }); chat/completions
ignores serverTools. extraBody is merged into whichever endpoint's body is sent, so its
keys must be that endpoint's params (e.g. reasoning, not reasoning_effort, on Responses).
Missing OPENAI_API_KEY fails fast: the provider throws naming the exact env var on
first use (the exported provider is a lazy proxy, so this fires on the first chat() call,
not at bond/module-load time) — it never silently sends an empty key.
Error message disambiguation: a plain 400 that ISN'T a context-length error (bad param, malformed tool schema) gets its own non-retryable message distinct from the generic "AI service error. Please try again." used for retryable failures.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual chat/AI screens, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip. The sandbox HAS an AI provider bonded, so the flow runs live end-to-end; AI output is NON-DETERMINISTIC, so assert on STRUCTURE/behavior, not exact text:
- [ ] A message sent through the real chat UI comes back as a RELEVANT AI reply — not an echo of the prompt, a hardcoded stub, or an empty bubble. Ask something with a checkable answer (e.g. "What is 2 + 2?") and confirm the response actually contains it ("4"), proving a live model answered.
- [ ] If the app streams, tokens render INCREMENTALLY — text grows word by
word in the UI, not one final blob dumped after a long frozen spinner. (A
streamed
chat()yieldstextchunks then a finaldone; a single late blob means the reply was awaited whole and streaming is broken.) - [ ] Multi-turn CONTEXT is preserved: a follow-up that refers back to the
previous turn (e.g. after "2 + 2", ask "now double that" -> understood as 8) works — proving the full
messageshistory is sent, not just the last line. - [ ] A provider failure (bad/missing key, rate limit, timeout) surfaces as a graceful in-UI error message, NOT a crash, blank screen, a spinner that never resolves, or an unhandled 500. Force one and watch the UI recover.
- [ ] The provider key + the provider call are SERVER-side only: the key
never reaches the browser (check the network tab, the JS bundle, and page
globals), and no route proxies arbitrary prompts to the model without auth
- a token cap — an open AI endpoint is an unbounded bill and abuse vector.
