UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

524 lines (523 loc) • 23.8 kB
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { AIProviderName as AIProviderNameEnum } from "../../constants/enums.js"; import { createProxyFetch } from "../../proxy/proxyFetch.js"; import { AuthenticationError, InvalidModelError, ProviderError, RateLimitError, } from "../../types/index.js"; import { logger } from "../../utils/logger.js"; import { redactUrlCredentials } from "../../utils/logSanitize.js"; import { calculateCost } from "../../utils/pricing.js"; import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js"; import { createOpenAIConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js"; import { MAX_IMAGE_BYTES, readBoundedBuffer } from "../../utils/sizeGuard.js"; import { assertSafeUrl } from "../../utils/ssrfGuard.js"; import { createTimeoutController } from "../../utils/timeout.js"; import { stripTrailingSlash } from "../openaiChatCompletionsClient.js"; import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js"; import { isOpenAIQuotaExhaustedError, readOpenAIBodyErrorType, } from "../../utils/providerRetry.js"; const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1"; /** * Resolve the effective OpenAI base URL from optional credential / env * overrides, falling back to the official API host. * * - Blank or whitespace-only overrides are treated as unset, so a bare * `OPENAI_BASE_URL=` cannot silently override the default with "". * - The official OpenAI REST API lives under `/v1`. Setup guidance has long * shown `OPENAI_BASE_URL="https://api.openai.com"` (no path); consumed * verbatim that builds `https://api.openai.com/chat/completions` and 404s. * When the canonical host is supplied without a path, append `/v1`. Custom * proxy hosts (LiteLLM, gateways, …) are left exactly as provided. */ const resolveOpenAIBaseURL = (credentialBaseURL, envBaseURL) => { const resolved = [credentialBaseURL, envBaseURL] .map((v) => v?.trim()) .find((v) => !!v && v.length > 0) ?? OPENAI_DEFAULT_BASE_URL; try { const url = new URL(resolved); const hasPath = url.pathname && url.pathname !== "/"; if (url.hostname === "api.openai.com" && !hasPath) { url.pathname = "/v1"; return stripTrailingSlash(url.toString()); } } catch { // Not a parseable absolute URL — return the override verbatim. } return resolved; }; const getOpenAIApiKey = () => validateApiKey(createOpenAIConfig()); const getOpenAIModel = () => getProviderModel("OPENAI_MODEL", "gpt-5.4"); const streamTracer = trace.getTracer("neurolink.provider.openai"); /** * OpenAI Provider — direct HTTP, no AI SDK. * * OpenAI chat completions at api.openai.com/v1. All request / stream / * tool-loop orchestration lives in `OpenAIChatCompletionsProvider`; this * class adds: * - OTel span wrap with cost (`onStreamStart`) * - Native `/v1/embeddings` (`embed` / `embedMany`) * - Image generation via `/v1/images/generations` (`executeImageGeneration`) * - OpenAI-specific error mapping (`formatProviderError`) * * @see https://platform.openai.com/docs/api-reference */ export class OpenAIProvider extends OpenAIChatCompletionsProvider { constructor(modelName, sdk, _region, credentials) { const overrideApiKey = credentials?.apiKey?.trim(); const apiKey = overrideApiKey && overrideApiKey.length > 0 ? overrideApiKey : getOpenAIApiKey(); const baseURL = resolveOpenAIBaseURL(credentials?.baseURL, process.env.OPENAI_BASE_URL); super(AIProviderNameEnum.OPENAI, modelName, sdk, { baseURL, apiKey }); logger.debug("OpenAIProvider initialized", { model: this.modelName, providerName: this.providerName, baseURL: redactUrlCredentials(this.config.baseURL), }); } // =========================================================================== // Abstract hook implementations // =========================================================================== /** * OpenAI natively supports `response_format: json_schema` together with * tool calling in one request, so structured output stays wire-enforced * mid-loop instead of deferring to post-hoc coercion. */ suppressResponseFormatWithTools() { return false; } getProviderName() { return AIProviderNameEnum.OPENAI; } getDefaultModel() { return getOpenAIModel(); } formatProviderError(error) { // Chat errors retain the JSON in responseBody; embedding errors stamp // type onto the raw error. Keep both paths available to the rule closures. const errorObj = error; const errorType = errorObj?.type && typeof errorObj.type === "string" ? errorObj.type : readOpenAIBodyErrorType(errorObj?.responseBody); const rules = [ // Curator P1-1 / Reviewer Finding #4: only the explicit auth markers // map to AuthenticationError. Earlier we treated every // `invalid_request_error` as an auth failure — that's OpenAI's // catch-all for any bad request (unsupported parameter, malformed // JSON, etc.) and mislabelled them as "invalid API key". Use // credential-specific signals only. { match: (ctx) => ctx.statusCode === 401 || errorType === "invalid_api_key" || /API_KEY_INVALID|Invalid API key|Incorrect API key|invalid_api_key/i.test(ctx.message), errorClass: AuthenticationError, message: (ctx) => /Incorrect API key|Invalid API key/i.test(ctx.message) ? ctx.message : "Invalid OpenAI API key. Please check your OPENAI_API_KEY environment variable.", }, // MUST precede the 429 rule below. OpenAI returns 429 for two // unrelated conditions: transient throttling, and a permanently // exhausted quota (out of credit, or a spend cap reached). Matching on // the status code alone collapses them, and the resulting advice — // "try again later" — is the one thing that can never resolve the // second. The signal is the error TYPE, not the wording. { // One definition of "permanent billing state", in the helper. The // earlier cut also tested `errorType === "insufficient_quota"` here, // which is precisely the helper's own first branch — two places to // keep in step for no benefit. match: (ctx) => isOpenAIQuotaExhaustedError(ctx.error), errorClass: ProviderError, message: "OpenAI quota exhausted — this will not resolve by retrying. " + "Check your plan and billing details at " + "https://platform.openai.com/account/billing", }, { match: (ctx) => ctx.statusCode === 429 || errorType === "rate_limit_error" || /rate limit/i.test(ctx.message), errorClass: RateLimitError, message: "OpenAI rate limit exceeded. Please try again later.", }, { match: (ctx) => /model_not_found/i.test(ctx.message), errorClass: InvalidModelError, message: (ctx) => `Model not found: ${ctx.modelName}`, }, ...DEFAULT_ERROR_RULES, ]; return classifyProviderError(error, rules, this.providerName, this.modelName); } // =========================================================================== // Optional hook overrides // =========================================================================== /** * Wrap the stream in an OTel span to capture provider-level latency, * token usage, finish reason, and cost. Mirrors the pre-migration * `streamText`-span behaviour. */ onStreamStart(modelId) { const span = streamTracer.startSpan("neurolink.provider.streamText", { kind: SpanKind.CLIENT, attributes: { "gen_ai.system": "openai", "gen_ai.request.model": modelId, }, }); let spanEnded = false; const endSpan = () => { if (!spanEnded) { spanEnded = true; span.end(); } }; return { onUsage: (usage) => { // promptTokens is the uncached remainder — the attribute reports the // full prompt (uncached + cache read/creation), and the cache fields // let calculateCost price tiers. span.setAttribute("gen_ai.usage.input_tokens", usage.promptTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0)); span.setAttribute("gen_ai.usage.output_tokens", usage.completionTokens); const cost = calculateCost(this.providerName, modelId, { input: usage.promptTokens, output: usage.completionTokens, total: usage.totalTokens, ...(usage.cacheReadTokens ? { cacheReadTokens: usage.cacheReadTokens } : {}), ...(usage.cacheCreationTokens ? { cacheCreationTokens: usage.cacheCreationTokens } : {}), }); if (cost && cost > 0) { span.setAttribute("neurolink.cost", cost); } }, onFinish: (reason, capturedError) => { span.setAttribute("gen_ai.response.finish_reason", reason || "unknown"); if (reason === "error") { span.setStatus({ code: SpanStatusCode.ERROR, message: capturedError instanceof Error ? capturedError.message : String(capturedError ?? "stream error"), }); } endSpan(); }, }; } // =========================================================================== // Embeddings — native POST /v1/embeddings // =========================================================================== /** * Default embedding model, overridable via OPENAI_EMBEDDING_MODEL env var. */ getDefaultEmbeddingModel() { return process.env.OPENAI_EMBEDDING_MODEL || "text-embedding-3-small"; } /** * Generate an embedding for a single text input via native /v1/embeddings. * * @param input - The text to embed (string or EmbedInput) * @param modelName - The embedding model to use (default: text-embedding-3-small) * @returns Promise resolving to the embedding vector */ async embed(input, modelName) { if (typeof input !== "string" && input.image) { throw new ProviderError(`${this.providerName} does not support image embeddings; provide text input`, this.providerName); } const text = typeof input === "string" ? input : (input.text ?? ""); const embeddingModelName = modelName || this.getDefaultEmbeddingModel(); logger.debug("Generating embedding", { provider: this.providerName, model: embeddingModelName, textLength: text.length, }); try { const [embedding] = await this.callEmbeddings(embeddingModelName, [text], "embed"); logger.debug("Embedding generated successfully", { provider: this.providerName, model: embeddingModelName, embeddingDimension: embedding.length, }); return embedding; } catch (error) { logger.error("Embedding generation failed", { error: error instanceof Error ? error.message : String(error), model: embeddingModelName, textLength: text.length, }); throw this.handleProviderError(error); } } /** * Generate embeddings for multiple texts in a single batch via native /v1/embeddings. * * @param texts - The texts to embed * @param modelName - The embedding model to use (default: text-embedding-3-small) * @returns Promise resolving to an array of embedding vectors */ async embedMany(texts, modelName) { const embeddingModelName = modelName || this.getDefaultEmbeddingModel(); logger.debug("Generating batch embeddings", { provider: this.providerName, model: embeddingModelName, count: texts.length, }); try { const embeddings = await this.callEmbeddings(embeddingModelName, texts, "embedMany"); logger.debug("Batch embeddings generated successfully", { provider: this.providerName, model: embeddingModelName, count: embeddings.length, embeddingDimension: embeddings[0]?.length, }); return embeddings; } catch (error) { logger.error("Batch embedding generation failed", { error: error instanceof Error ? error.message : String(error), model: embeddingModelName, count: texts.length, }); throw this.handleProviderError(error); } } async callEmbeddings(modelName, input, operation) { const url = `${stripTrailingSlash(this.config.baseURL)}/embeddings`; const fetchImpl = createProxyFetch(); const timeoutController = createTimeoutController(30_000, this.providerName, "generate"); try { const res = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.apiKey}`, }, body: JSON.stringify({ model: modelName, input: input.length === 1 ? input[0] : input, }), ...(timeoutController?.controller.signal ? { signal: timeoutController.controller.signal } : {}), }); if (!res.ok) { const bodyText = await res.text().catch(() => ""); let message = `OpenAI ${operation} failed with status ${res.status}`; let errorType; if (bodyText) { try { const parsed = JSON.parse(bodyText); if (parsed.error?.message) { message = parsed.error.message; } errorType = parsed.error?.type; } catch { // Non-JSON error body — keep the status-based message. } } // Throw a raw, annotated error so the public embed/embedMany catch // formats it exactly once via handleProviderError(), preserving // status-based classification (401 -> auth, 429 -> rate limit, …). throw Object.assign(new Error(message), { status: res.status, type: errorType, }); } const json = (await res.json()); const embeddings = (json.data ?? []) .map((row) => row.embedding) .filter((e) => Array.isArray(e)); if (embeddings.length === 0) { throw new ProviderError(`OpenAI ${operation} returned no embeddings`, this.providerName); } return embeddings; } finally { timeoutController?.cleanup(); } } // =========================================================================== // Image generation — native POST /v1/images/generations // =========================================================================== /** * Image generation via the OpenAI Images API (`/v1/images/generations`). * * Supports `gpt-image-1`, `dall-e-3`, and `dall-e-2`. The three models * differ in which body params they accept: * * - `gpt-image-1` returns base64 by default; does NOT accept `response_format`. * - `dall-e-3` / `dall-e-2` accept `response_format: "b64_json"` to get base64. * - `dall-e-2` does NOT accept `quality` / `style`. * * The model is taken from `options.model || this.modelName`. * * @see https://platform.openai.com/docs/api-reference/images/create */ async executeImageGeneration(options) { const startTime = Date.now(); const prompt = options.prompt ?? options.input?.text ?? ""; if (!prompt.trim()) { throw new Error("OpenAI image generation requires a prompt (input.text or prompt)"); } const model = options.model ?? this.modelName; const baseURL = stripTrailingSlash(this.config.baseURL ?? OPENAI_DEFAULT_BASE_URL); // Image-gen extras live on `options` but are not part of the strict // TextGenerationOptions shape — cast to a permissive type to read them. const extras = options; // Map aspect ratio to OpenAI's `size` parameter. gpt-image-1 supports // 1024x1024 / 1024x1536 / 1536x1024 / auto; dall-e-3 supports // 1024x1024 / 1792x1024 / 1024x1792; dall-e-2 supports 256x256 / // 512x512 / 1024x1024. We pick safe defaults and let users override // via `extras.size` directly. const size = extras.size ?? this.aspectRatioToOpenAISize(extras.aspectRatio, model); // Clamp n per-model: gpt-image-1 and dall-e-3 only support n=1; // dall-e-2 supports n=1..10; default to 1 for any future models. const rawN = extras.numberOfImages ?? 1; let clampedN; if (model === "gpt-image-1" || model.startsWith("dall-e-3")) { clampedN = 1; } else if (model.startsWith("dall-e-2")) { clampedN = Math.min(Math.max(rawN, 1), 10); } else { clampedN = 1; } const n = clampedN; const body = { model, prompt, n, size, }; if (model === "gpt-image-1") { // gpt-image-1 always returns base64; rejects `response_format`. if (extras.quality) { body.quality = extras.quality; } } else if (model.startsWith("dall-e-3")) { body.response_format = "b64_json"; if (extras.quality) { body.quality = extras.quality; } if (extras.style) { body.style = extras.style; } } else { // dall-e-2 (and forward-compat default). body.response_format = "b64_json"; } const REQUEST_TIMEOUT_MS = 120_000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); let response; try { const proxyFetch = createProxyFetch(); response = await proxyFetch(`${baseURL}/images/generations`, { method: "POST", headers: { Authorization: `Bearer ${this.config.apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(body), signal: controller.signal, }); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new Error(`OpenAI image generation timed out after ${REQUEST_TIMEOUT_MS / 1000}s`, { cause: err }); } throw err; } finally { clearTimeout(timeoutId); } if (!response.ok) { const text = await response.text(); throw new Error(`OpenAI image generation failed: ${response.status} — ${text}`); } const data = (await response.json()); const first = data.data?.[0]; if (!first) { throw new Error("OpenAI image generation returned no images"); } let base64 = first.b64_json; // dall-e-2 with `response_format: "b64_json"` should always include // b64_json. If a hosted URL came back instead (e.g. older keys, or // url-mode), download it inline so callers always get base64. if (!base64 && first.url) { // Guard the API-returned URL before fetching (provider-returned URLs // carry the same SSRF risk as caller-supplied ones). await assertSafeUrl(first.url); const proxyFetch = createProxyFetch(); const dlController = new AbortController(); const dlTimeoutId = setTimeout(() => dlController.abort(), 60_000); let imgResp; try { imgResp = await proxyFetch(first.url, { signal: dlController.signal }); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new Error("OpenAI image URL download timed out after 60s", { cause: err, }); } throw err; } finally { clearTimeout(dlTimeoutId); } if (!imgResp.ok) { throw new Error(`OpenAI image generation: failed to fetch hosted URL ${first.url} (${imgResp.status})`); } const buf = await readBoundedBuffer(imgResp, MAX_IMAGE_BYTES, "OpenAI image fallback"); base64 = buf.toString("base64"); } if (!base64) { throw new Error("OpenAI image generation returned neither b64_json nor a URL"); } const generationTimeMs = Date.now() - startTime; logger.info(`[OpenAIProvider] Generated image (${base64.length} base64 chars) in ${generationTimeMs}ms — model ${model}`); return { content: first.revised_prompt ?? prompt, provider: this.providerName, model, usage: { input: 0, output: 0, total: 0 }, imageOutput: { base64 }, }; } /** * Map a NeuroLink-style aspect ratio (e.g. "16:9") to the OpenAI * `size` parameter accepted by the active image model. Falls back to * the per-model square default when the ratio is unknown. */ aspectRatioToOpenAISize(aspectRatio, model) { if (model === "gpt-image-1") { if (aspectRatio === "16:9" || aspectRatio === "3:2") { return "1536x1024"; } if (aspectRatio === "9:16" || aspectRatio === "2:3") { return "1024x1536"; } return "1024x1024"; } if (model.startsWith("dall-e-3")) { if (aspectRatio === "16:9" || aspectRatio === "3:2") { return "1792x1024"; } if (aspectRatio === "9:16" || aspectRatio === "2:3") { return "1024x1792"; } return "1024x1024"; } // dall-e-2 — only square sizes supported. return "1024x1024"; } }