{"version":3,"file":"adapter.mjs","names":["#options","#buildRequestBody","#url","#headers","#sendRequest","#mapResponse"],"sources":["../../../../src/batteries/generation/gemini/adapter.ts"],"sourcesContent":["/**\n * Cross-environment Gemini media generation adapter battery.\n *\n * @module @nhtio/adk/batteries/generation/gemini/adapter\n *\n * @remarks\n * Opinionated media-generation battery for the native Gemini `generativelanguage` REST surface —\n * `POST /models/{model}:generateContent` — over raw `fetch`. No `@google/genai` SDK dependency, so\n * it runs unchanged in Node, the browser, edge runtimes, and workers.\n *\n * Construction validates options eagerly via\n * {@link @nhtio/adk/batteries/generation/gemini/validation!validateOptions} and throws\n * {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_INVALID_GEMINI_GENERATION_OPTIONS} on\n * failure — config bugs fail loud, not at generate/edit time.\n */\n\nimport { toBytes } from '../_shared'\nimport { isError } from '@nhtio/adk/guards'\nimport { validateOptions } from './validation'\nimport { decodeBase64, encodeBase64 } from '../../../lib/helpers/base64'\nimport { computeBackoff, sleepWithJitter, parseRetryAfter } from '../../../lib/utils/retry'\nimport {\n  E_GEMINI_GENERATION_HTTP_ERROR,\n  E_GEMINI_GENERATION_REQUEST_TIMEOUT,\n  E_GEMINI_GENERATION_MALFORMED_RESPONSE,\n} from './exceptions'\nimport type { GenerationImageInput } from '../_shared'\nimport type {\n  GenerationRetryConfig,\n  GeneratedMediaOutput,\n  GeminiGenerationAdapterOptions,\n  GeminiGenerateOptions,\n  GeminiEditOptions,\n  GeminiRequestPart,\n  GeminiGenerateContentRequestBody,\n  GeminiGenerateContentResponse,\n  GeminiResponsePart,\n} from './types'\n\n// ─── Wire helpers ──────────────────────────────────────────────────────────────\n\n/** Default request base URL — probe-confirmed `generateContent` surface. */\nconst DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'\n\n/** Derives a filename extension from a MIME type (e.g. `'image/jpeg'` → `'jpg'`). */\nconst extensionFromMimeType = (mimeType: string): string =>\n  mimeType.toLowerCase().split(';')[0].trim().split('/')[1]?.replace('jpeg', 'jpg') ?? 'png'\n\n/** Reads the `inlineData`/`inline_data` payload off a response part, tolerating both casings. */\nconst inlineDataOf = (\n  part: GeminiResponsePart\n): { mimeType?: string; data: string } | undefined => {\n  if ('inlineData' in part && part.inlineData) return part.inlineData\n  if ('inline_data' in part && part.inline_data) return part.inline_data\n  return undefined\n}\n\n// ─── Adapter class ────────────────────────────────────────────────────────────\n\n/**\n * Media generation adapter for the native Gemini `generativelanguage` `generateContent` wire\n * shape.\n *\n * @remarks\n * Reusable: construct once, call {@link GeminiGenerationAdapter.generate} /\n * {@link GeminiGenerationAdapter.edit} as many times as needed.\n */\nexport class GeminiGenerationAdapter {\n  readonly #options: GeminiGenerationAdapterOptions\n\n  /**\n   * Whether this battery can run in the current environment. For the HTTP-backed Gemini battery\n   * this is always `true` (a `fetch` is always resolvable); present for surface-parity with\n   * WebGPU-gated batteries.\n   */\n  public static isAvailable(): boolean {\n    return true\n  }\n\n  /**\n   * @param options - Constructor options. Validated eagerly.\n   * @throws {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_INVALID_GEMINI_GENERATION_OPTIONS} when `options` does not satisfy\n   *   {@link @nhtio/adk/batteries/generation/gemini/validation!geminiGenerationOptionsSchema} (e.g. missing `model`).\n   */\n  constructor(options: unknown) {\n    this.#options = validateOptions(options)\n  }\n\n  /** See {@link GeminiGenerationAdapter.isAvailable}. Instance alias for surface-parity. */\n  isAvailable(): boolean {\n    return GeminiGenerationAdapter.isAvailable()\n  }\n\n  /**\n   * No-op warm-up. The Gemini battery has no engine to preload; present for surface-parity with\n   * on-device generation batteries so callers can treat them interchangeably.\n   */\n  async preload(): Promise<void> {\n    // intentionally empty — nothing to warm for an HTTP-backed battery\n  }\n\n  /**\n   * No-op state reset. Present for surface-parity with on-device generation batteries.\n   */\n  reset(): void {\n    // intentionally empty — the Gemini battery holds no engine state\n  }\n\n  /**\n   * Generates one or more images from a text prompt.\n   *\n   * @param prompt - The text prompt describing the desired image(s).\n   * @param opts - Per-call options (`n`, `aspectRatio`), each falling back to the adapter's\n   *   configured default when applicable.\n   * @returns One {@link GeneratedMediaOutput} per generated image part in the response.\n   * @throws {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_GEMINI_GENERATION_HTTP_ERROR} on a non-2xx response or transport failure.\n   * @throws {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_GEMINI_GENERATION_REQUEST_TIMEOUT} when the handshake exceeds `requestTimeoutMs`.\n   * @throws {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_GEMINI_GENERATION_MALFORMED_RESPONSE} when the 2xx body is not the expected shape, or contains zero image parts.\n   */\n  async generate(prompt: string, opts?: GeminiGenerateOptions): Promise<GeneratedMediaOutput[]> {\n    const parts: GeminiRequestPart[] = [{ text: prompt }]\n    const body = this.#buildRequestBody(parts, opts)\n    const url = this.#url(this.#options.model)\n    const headers = this.#headers()\n    const response = await this.#sendRequest(url, headers, JSON.stringify(body))\n    return this.#mapResponse(response)\n  }\n\n  /**\n   * Edits one or more source images from a text prompt.\n   *\n   * @param inputs - One image, or several images, in any {@link GenerationImageInput} form.\n   * @param prompt - The text prompt describing the desired edit.\n   * @param opts - Per-call options (`n`, `aspectRatio`), each falling back to the adapter's\n   *   configured default when applicable.\n   * @returns One {@link GeneratedMediaOutput} per edited image part in the response.\n   * @throws {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_GEMINI_GENERATION_HTTP_ERROR} on a non-2xx response or transport failure.\n   * @throws {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_GEMINI_GENERATION_REQUEST_TIMEOUT} when the handshake exceeds `requestTimeoutMs`.\n   * @throws {@link @nhtio/adk/batteries/generation/gemini/exceptions!E_GEMINI_GENERATION_MALFORMED_RESPONSE} when the 2xx body is not the expected shape, or contains zero image parts.\n   */\n  async edit(\n    inputs: GenerationImageInput | GenerationImageInput[],\n    prompt: string,\n    opts?: GeminiEditOptions\n  ): Promise<GeneratedMediaOutput[]> {\n    const normalizedInputs = await Promise.all(\n      (Array.isArray(inputs) ? inputs : [inputs]).map((input) => toBytes(input))\n    )\n\n    // Probe-confirmed ordering: image parts first (in input order), text prompt last.\n    const parts: GeminiRequestPart[] = normalizedInputs.map(({ bytes, mimeType }) => ({\n      inlineData: {\n        mimeType: mimeType ?? 'image/png',\n        data: encodeBase64(bytes),\n      },\n    }))\n    parts.push({ text: prompt })\n\n    const body = this.#buildRequestBody(parts, opts)\n    const url = this.#url(this.#options.model)\n    const headers = this.#headers()\n    const response = await this.#sendRequest(url, headers, JSON.stringify(body))\n    return this.#mapResponse(response)\n  }\n\n  // ─── internals ──────────────────────────────────────────────────────────────\n\n  #buildRequestBody(\n    parts: GeminiRequestPart[],\n    opts?: GeminiGenerateOptions | GeminiEditOptions\n  ): GeminiGenerateContentRequestBody {\n    const responseModalities = this.#options.responseModalities ?? ['TEXT', 'IMAGE']\n    const aspectRatio = opts?.aspectRatio ?? this.#options.aspectRatio\n\n    const generationConfig: GeminiGenerateContentRequestBody['generationConfig'] = {\n      responseModalities,\n      ...(opts?.n !== undefined && opts.n > 1 ? { candidateCount: opts.n } : {}),\n      ...(aspectRatio !== undefined ? { imageConfig: { aspectRatio } } : {}),\n    }\n\n    return {\n      contents: [{ role: 'user', parts }],\n      generationConfig,\n    }\n  }\n\n  #url(model: string): string {\n    const rawBase = this.#options.baseURL ?? DEFAULT_BASE_URL\n    const baseURL = rawBase.endsWith('/') ? rawBase.slice(0, -1) : rawBase\n    return `${baseURL}/models/${model}:generateContent`\n  }\n\n  #headers(): Record<string, string> {\n    const headers: Record<string, string> = { 'Content-Type': 'application/json' }\n    if (this.#options.apiKey) {\n      headers['x-goog-api-key'] = this.#options.apiKey\n    }\n    if (this.#options.headers) {\n      Object.assign(headers, this.#options.headers)\n    }\n    return headers\n  }\n\n  #mapResponse(response: GeminiGenerateContentResponse): GeneratedMediaOutput[] {\n    if (!response || !Array.isArray(response.candidates) || response.candidates.length === 0) {\n      throw new E_GEMINI_GENERATION_MALFORMED_RESPONSE(['response contained no candidates'])\n    }\n\n    const allParts = response.candidates.flatMap((candidate) => candidate.content?.parts ?? [])\n\n    const outputs: GeneratedMediaOutput[] = []\n    const textParts: string[] = []\n    for (const part of allParts) {\n      const inline = inlineDataOf(part)\n      if (inline) {\n        const mimeType = inline.mimeType ?? 'image/png'\n        outputs.push({\n          kind: 'image',\n          mimeType,\n          bytes: decodeBase64(inline.data),\n          filename: `generated-${outputs.length + 1}.${extensionFromMimeType(mimeType)}`,\n        })\n      } else if ('text' in part && typeof part.text === 'string') {\n        textParts.push(part.text)\n      }\n    }\n\n    if (outputs.length === 0) {\n      const detail =\n        textParts.length > 0\n          ? `response contained no image parts; text: ${textParts.join(' ')}`\n          : 'response contained no image parts'\n      throw new E_GEMINI_GENERATION_MALFORMED_RESPONSE([detail])\n    }\n\n    return outputs\n  }\n\n  // Shared HTTP request core — clones the OpenAI Generation adapter's retry loop exactly\n  // (AbortController + requestTimeoutMs (0=disabled), retriable statuses, Retry-After honor,\n  // transport-error status 0, exhaustion throws last HTTP error) so retry/timeout/error semantics\n  // stay identical across every bundled HTTP-backed generation battery.\n  async #sendRequest(\n    url: string,\n    headers: Record<string, string>,\n    body: BodyInit\n  ): Promise<GeminiGenerateContentResponse> {\n    const retryCfg: Required<GenerationRetryConfig> = {\n      maxAttempts: this.#options.retry?.maxAttempts ?? 1,\n      baseDelayMs: this.#options.retry?.baseDelayMs ?? 500,\n      maxDelayMs: this.#options.retry?.maxDelayMs ?? 30_000,\n      retriableStatuses: this.#options.retry?.retriableStatuses ?? [429, 500, 502, 503, 504],\n      honorRetryAfter: this.#options.retry?.honorRetryAfter ?? true,\n    }\n\n    const fetchFn = this.#options.fetch ?? globalThis.fetch\n    const requestTimeoutMs = this.#options.requestTimeoutMs ?? 0\n    const maxAttempts = retryCfg.maxAttempts\n\n    let attempt = 1\n    while (attempt <= maxAttempts) {\n      const controller = new AbortController()\n      let timeoutHandle: ReturnType<typeof setTimeout> | undefined\n      if (requestTimeoutMs > 0) {\n        timeoutHandle = setTimeout(() => controller.abort(), requestTimeoutMs)\n      }\n\n      let response: Response\n      try {\n        response = await fetchFn(url, {\n          method: 'POST',\n          headers,\n          body,\n          signal: controller.signal,\n        })\n      } catch (err) {\n        if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)\n        if (controller.signal.aborted) {\n          // Timed out before headers — retry if attempts remain.\n          if (attempt < maxAttempts) {\n            await sleepWithJitter(computeBackoff(attempt, retryCfg))\n            attempt += 1\n            continue\n          }\n          throw new E_GEMINI_GENERATION_REQUEST_TIMEOUT([requestTimeoutMs])\n        }\n        // Generic transport failure — retry if attempts remain, else surface as status 0.\n        if (attempt < maxAttempts) {\n          await sleepWithJitter(computeBackoff(attempt, retryCfg))\n          attempt += 1\n          continue\n        }\n        throw new E_GEMINI_GENERATION_HTTP_ERROR([0, isError(err) ? err.message : String(err)])\n      }\n      if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)\n\n      if (!response.ok) {\n        const status = response.status\n        const retriable = retryCfg.retriableStatuses.includes(status)\n        if (retriable && attempt < maxAttempts) {\n          let delay = computeBackoff(attempt, retryCfg)\n          if (retryCfg.honorRetryAfter) {\n            const ra = response.headers.get('Retry-After')\n            if (ra) {\n              const raMs = parseRetryAfter(ra)\n              if (raMs > 0) delay = Math.min(Math.max(delay, raMs), retryCfg.maxDelayMs)\n            }\n          }\n          await sleepWithJitter(delay)\n          attempt += 1\n          continue\n        }\n        const detail = await response.text().catch(() => '')\n        throw new E_GEMINI_GENERATION_HTTP_ERROR([status, detail])\n      }\n\n      try {\n        return (await response.json()) as GeminiGenerateContentResponse\n      } catch (err) {\n        throw new E_GEMINI_GENERATION_MALFORMED_RESPONSE([isError(err) ? err.message : String(err)])\n      }\n    }\n\n    // Unreachable: the loop either returns or throws. Satisfies the type checker.\n    throw new E_GEMINI_GENERATION_HTTP_ERROR([0, 'retry loop exhausted without a response'])\n  }\n}\n"],"mappings":"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"}