Răsfoiți Sursa

fix(embed): honor native Gemini key (omit-index + factory fallback)

QMD rejected Gemini OpenAI-compat responses that omit data[].index
(session 7fcfc297 on zdrowie) and ignored GEMINI_API_KEY unless
QMD_EMBED_ENDPOINT was set, so the crew rewrote config to ai.mm.mk and 401'd.

- Treat missing embedding index as input order
- If GEMINI_API_KEY/GOOGLE_API_KEY is set and no endpoint, use Gemini
  OpenAI-compat (gemini-embedding-001)
- Status reports native-key / config.json, not only QMD_EMBED_ENDPOINT

Session-Id: 01a01263
Claude 1 săptămână în urmă
părinte
comite
03c43a2926

+ 13 - 2
dist/cli/qmd.js

@@ -12,8 +12,19 @@ import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, setDefaultLlamaCpp, LlamaCp
 import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
 import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, removeCollection as yamlRemoveCollectionFn, renameCollection as yamlRenameCollectionFn, setGlobalContext, listAllContexts, setConfigIndexName, loadConfig, } from "../collections.js";
 import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
-import { createEmbeddingProvider, ModelMismatchError, } from "../embedding/index.js";
+import { createEmbeddingProvider, loadConfigFile, ModelMismatchError, } from "../embedding/index.js";
 import { commercialApiHold } from "../model-policy.js";
+function describeEmbeddingConfig() {
+    if (process.env.QMD_EMBED_ENDPOINT?.trim())
+        return "configured (QMD_EMBED_ENDPOINT)";
+    const cfg = loadConfigFile();
+    if (cfg.embedProvider?.endpoint?.trim())
+        return "configured (config.json)";
+    if ((process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "").trim()) {
+        return "configured (native GEMINI_API_KEY)";
+    }
+    return "HOLD (no QMD_EMBED_ENDPOINT / config.json endpoint / GEMINI_API_KEY)";
+}
 // Enable production mode - allows using default database path
 // Tests must set INDEX_PATH or use createStore() with explicit path
 enableProductionMode();
@@ -347,7 +358,7 @@ async function showStatus() {
     }
     console.log(`\n${c.bold}Learned model policy${c.reset}`);
     console.log("  Runtime:     commercial API only");
-    console.log(`  Embeddings:  ${process.env.QMD_EMBED_ENDPOINT ? "configured" : "HOLD (QMD_EMBED_ENDPOINT missing)"}`);
+    console.log(`  Embeddings:  ${describeEmbeddingConfig()}`);
     console.log("  Local model: disabled");
     // Tips section
     const tips = [];

+ 4 - 0
dist/embedding/factory.d.ts

@@ -52,6 +52,10 @@ export type CreateEmbeddingProviderOptions = {
  * logging and tests.
  */
 export declare function resolveProviderKind(opts?: CreateEmbeddingProviderOptions): ProviderKind;
+/** Google Gemini OpenAI-compat embeddings base (qmd appends `/v1/embeddings`). */
+export declare const GEMINI_OPENAI_EMBED_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/openai";
+export declare const GEMINI_EMBED_MODEL = "gemini-embedding-001";
+export declare function isGeminiEmbeddingsHost(endpoint: string): boolean;
 /**
  * Factory entry point — returns the appropriate `EmbeddingProvider`.
  * Throws if `openai` kind is requested but no endpoint is configured.

+ 35 - 7
dist/embedding/factory.js

@@ -83,6 +83,21 @@ export function resolveProviderKind(opts = {}) {
     // Commercial-only default. Missing endpoint is handled as typed HOLD by the factory.
     return "openai";
 }
+/** Google Gemini OpenAI-compat embeddings base (qmd appends `/v1/embeddings`). */
+export const GEMINI_OPENAI_EMBED_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/openai";
+export const GEMINI_EMBED_MODEL = "gemini-embedding-001";
+export function isGeminiEmbeddingsHost(endpoint) {
+    try {
+        return new URL(endpoint).hostname.toLowerCase().endsWith("generativelanguage.googleapis.com");
+    }
+    catch {
+        return false;
+    }
+}
+function nativeGeminiKey(env) {
+    const key = (env.GEMINI_API_KEY || env.GOOGLE_API_KEY || "").trim();
+    return key || undefined;
+}
 /**
  * Factory entry point — returns the appropriate `EmbeddingProvider`.
  * Throws if `openai` kind is requested but no endpoint is configured.
@@ -91,23 +106,36 @@ export function createEmbeddingProvider(opts = {}) {
     const env = opts.env ?? process.env;
     const cfg = loadConfigFile(opts.configPath);
     resolveProviderKind(opts);
-    // OpenAI
-    const endpoint = opts.openai?.endpoint ??
+    const geminiKey = nativeGeminiKey(env);
+    // OpenAI (or Gemini's OpenAI-compat layer, using the native Gemini key)
+    let endpoint = opts.openai?.endpoint ??
         env.QMD_EMBED_ENDPOINT ??
         cfg.embedProvider?.endpoint;
     if (!endpoint || endpoint.trim() === "") {
-        throw commercialApiHold('commercial provider requires an endpoint. ' +
-            "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
-            "~/.config/qmd/config.json, or pass `openai.endpoint`.");
+        if (geminiKey) {
+            endpoint = GEMINI_OPENAI_EMBED_ENDPOINT;
+        }
+        else {
+            throw commercialApiHold('commercial provider requires an endpoint. ' +
+                "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
+                "~/.config/qmd/config.json, or pass `openai.endpoint`. " +
+                "A GEMINI_API_KEY / GOOGLE_API_KEY is also enough — qmd then uses Gemini native embeddings.");
+        }
     }
     assertCommercialEndpoint(endpoint);
-    const apiKey = opts.openai?.apiKey ??
+    let apiKey = opts.openai?.apiKey ??
         env.QMD_EMBED_API_KEY ??
         cfg.embedProvider?.apiKey;
-    const modelId = opts.openai?.modelId ??
+    if (!apiKey && isGeminiEmbeddingsHost(endpoint)) {
+        apiKey = geminiKey;
+    }
+    let modelId = opts.openai?.modelId ??
         env.QMD_EMBED_MODEL_ID ??
         cfg.embedProvider?.modelId ??
         "embeddinggemma";
+    if (isGeminiEmbeddingsHost(endpoint) && modelId === "embeddinggemma") {
+        modelId = GEMINI_EMBED_MODEL;
+    }
     const upstreamModel = opts.openai?.upstreamModel ??
         env.QMD_EMBED_UPSTREAM_MODEL ??
         cfg.embedProvider?.upstreamModel;

+ 2 - 2
dist/embedding/index.d.ts

@@ -2,5 +2,5 @@
  * embedding/index.ts - re-exports for the embedding provider abstraction.
  */
 export { type EmbeddingProvider, type ProviderKind, type ProviderEmbedding, type ProviderEmbedOptions, type ProviderHealth, ModelMismatchError, assertModelCompatible, } from "./provider.js";
-export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, type OpenAIProviderConfig, type CircuitState, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
-export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, type CreateEmbeddingProviderOptions, type EmbedProviderConfigFile, } from "./factory.js";
+export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, resolveOpenAIEmbeddingIndex, type OpenAIProviderConfig, type CircuitState, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
+export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, isGeminiEmbeddingsHost, GEMINI_OPENAI_EMBED_ENDPOINT, GEMINI_EMBED_MODEL, type CreateEmbeddingProviderOptions, type EmbedProviderConfigFile, } from "./factory.js";

+ 2 - 2
dist/embedding/index.js

@@ -2,5 +2,5 @@
  * embedding/index.ts - re-exports for the embedding provider abstraction.
  */
 export { ModelMismatchError, assertModelCompatible, } from "./provider.js";
-export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
-export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, } from "./factory.js";
+export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, resolveOpenAIEmbeddingIndex, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
+export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, isGeminiEmbeddingsHost, GEMINI_OPENAI_EMBED_ENDPOINT, GEMINI_EMBED_MODEL, } from "./factory.js";

+ 6 - 2
dist/embedding/openai.d.ts

@@ -3,7 +3,9 @@
  *
  * Talks to any endpoint that implements `POST /v1/embeddings` with the OpenAI
  * shape: request `{model, input: string|string[]}`, response
- * `{data: [{embedding: number[], index: number}, ...]}`.
+ * `{data: [{embedding: number[], index?: number}, ...]}`.
+ * `index` is optional: Gemini's OpenAI-compat layer omits it and returns
+ * embeddings in input order (session 7fcfc297). Missing index → array position.
  *
  * Used by qmd to delegate embeddings to an approved commercial HTTPS API.
  *
@@ -122,7 +124,7 @@ export type OpenAIEmbeddingsResponse = {
     model?: string;
     data: Array<{
         object?: string;
-        index: number;
+        index?: number;
         embedding: number[];
     }>;
     usage?: {
@@ -130,6 +132,8 @@ export type OpenAIEmbeddingsResponse = {
         total_tokens?: number;
     };
 };
+/** Map an OpenAI-compat embedding row onto the input slot. Missing `index` = row order. */
+export declare function resolveOpenAIEmbeddingIndex(index: unknown, position: number, inputCount: number): number;
 /**
  * Circuit breaker state — exported for tests
  */

+ 18 - 10
dist/embedding/openai.js

@@ -3,7 +3,9 @@
  *
  * Talks to any endpoint that implements `POST /v1/embeddings` with the OpenAI
  * shape: request `{model, input: string|string[]}`, response
- * `{data: [{embedding: number[], index: number}, ...]}`.
+ * `{data: [{embedding: number[], index?: number}, ...]}`.
+ * `index` is optional: Gemini's OpenAI-compat layer omits it and returns
+ * embeddings in input order (session 7fcfc297). Missing index → array position.
  *
  * Used by qmd to delegate embeddings to an approved commercial HTTPS API.
  *
@@ -78,6 +80,14 @@ export const CIRCUIT_WINDOW_MS = 60_000;
 export const CIRCUIT_OPEN_DURATION_MS = 5 * 60_000;
 export const CIRCUIT_FAILURE_RATE_THRESHOLD = 0.5;
 export const CIRCUIT_MIN_SAMPLES = 4;
+/** Map an OpenAI-compat embedding row onto the input slot. Missing `index` = row order. */
+export function resolveOpenAIEmbeddingIndex(index, position, inputCount) {
+    const idx = typeof index === "number" ? index : position;
+    if (!Number.isInteger(idx) || idx < 0 || idx >= inputCount) {
+        throw new Error(`OpenAIEmbeddingsProvider: data item index out of range (${String(index)}, expected 0..${inputCount - 1})`);
+    }
+    return idx;
+}
 // ─────────────────────────── Helpers ─────────────────────────────────────────
 function defaultSleep(ms) {
     return new Promise((resolve) => setTimeout(resolve, ms));
@@ -823,18 +833,16 @@ export class OpenAIEmbeddingsProvider {
             if (!parsed || !Array.isArray(parsed.data)) {
                 throw new Error(`OpenAIEmbeddingsProvider: response missing "data" array (got ${typeof parsed})`);
             }
-            // Sort by index to match input order (in case server returns out-of-order).
+            // Prefer `index` when present (out-of-order servers). Gemini's
+            // OpenAI-compat layer omits `index` and returns rows in input order.
             const out = new Array(texts.length);
-            for (const item of parsed.data) {
-                if (typeof item.index !== "number" ||
-                    item.index < 0 ||
-                    item.index >= texts.length) {
-                    throw new Error(`OpenAIEmbeddingsProvider: data item index out of range (${item.index}, expected 0..${texts.length - 1})`);
-                }
+            for (let i = 0; i < parsed.data.length; i++) {
+                const item = parsed.data[i];
+                const idx = resolveOpenAIEmbeddingIndex(item.index, i, texts.length);
                 if (!Array.isArray(item.embedding)) {
-                    throw new Error(`OpenAIEmbeddingsProvider: data[${item.index}].embedding is not an array`);
+                    throw new Error(`OpenAIEmbeddingsProvider: data[${idx}].embedding is not an array`);
                 }
-                out[item.index] = item.embedding;
+                out[idx] = item.embedding;
             }
             // Sanity check — every slot must be filled
             for (let i = 0; i < texts.length; i++) {

+ 12 - 1
src/cli/qmd.ts

@@ -101,6 +101,7 @@ import {
 import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
 import {
   createEmbeddingProvider,
+  loadConfigFile,
   type EmbeddingProvider,
   type ProviderKind,
   type CreateEmbeddingProviderOptions,
@@ -108,6 +109,16 @@ import {
 } from "../embedding/index.js";
 import { commercialApiHold } from "../model-policy.js";
 
+function describeEmbeddingConfig(): string {
+  if (process.env.QMD_EMBED_ENDPOINT?.trim()) return "configured (QMD_EMBED_ENDPOINT)";
+  const cfg = loadConfigFile();
+  if (cfg.embedProvider?.endpoint?.trim()) return "configured (config.json)";
+  if ((process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "").trim()) {
+    return "configured (native GEMINI_API_KEY)";
+  }
+  return "HOLD (no QMD_EMBED_ENDPOINT / config.json endpoint / GEMINI_API_KEY)";
+}
+
 // Enable production mode - allows using default database path
 // Tests must set INDEX_PATH or use createStore() with explicit path
 enableProductionMode();
@@ -467,7 +478,7 @@ async function showStatus(): Promise<void> {
 
   console.log(`\n${c.bold}Learned model policy${c.reset}`);
   console.log("  Runtime:     commercial API only");
-  console.log(`  Embeddings:  ${process.env.QMD_EMBED_ENDPOINT ? "configured" : "HOLD (QMD_EMBED_ENDPOINT missing)"}`);
+  console.log(`  Embeddings:  ${describeEmbeddingConfig()}`);
   console.log("  Local model: disabled");
 
   // Tips section

+ 40 - 9
src/embedding/factory.ts

@@ -131,6 +131,24 @@ export function resolveProviderKind(opts: CreateEmbeddingProviderOptions = {}):
   return "openai";
 }
 
+/** Google Gemini OpenAI-compat embeddings base (qmd appends `/v1/embeddings`). */
+export const GEMINI_OPENAI_EMBED_ENDPOINT =
+  "https://generativelanguage.googleapis.com/v1beta/openai";
+export const GEMINI_EMBED_MODEL = "gemini-embedding-001";
+
+export function isGeminiEmbeddingsHost(endpoint: string): boolean {
+  try {
+    return new URL(endpoint).hostname.toLowerCase().endsWith("generativelanguage.googleapis.com");
+  } catch {
+    return false;
+  }
+}
+
+function nativeGeminiKey(env: Record<string, string | undefined>): string | undefined {
+  const key = (env.GEMINI_API_KEY || env.GOOGLE_API_KEY || "").trim();
+  return key || undefined;
+}
+
 /**
  * Factory entry point — returns the appropriate `EmbeddingProvider`.
  * Throws if `openai` kind is requested but no endpoint is configured.
@@ -142,30 +160,43 @@ export function createEmbeddingProvider(
   const cfg = loadConfigFile(opts.configPath);
   resolveProviderKind(opts);
 
-  // OpenAI
-  const endpoint =
+  const geminiKey = nativeGeminiKey(env);
+
+  // OpenAI (or Gemini's OpenAI-compat layer, using the native Gemini key)
+  let endpoint =
     opts.openai?.endpoint ??
     env.QMD_EMBED_ENDPOINT ??
     cfg.embedProvider?.endpoint;
   if (!endpoint || endpoint.trim() === "") {
-    throw commercialApiHold(
-      'commercial provider requires an endpoint. ' +
-      "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
-      "~/.config/qmd/config.json, or pass `openai.endpoint`.",
-    );
+    if (geminiKey) {
+      endpoint = GEMINI_OPENAI_EMBED_ENDPOINT;
+    } else {
+      throw commercialApiHold(
+        'commercial provider requires an endpoint. ' +
+        "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
+        "~/.config/qmd/config.json, or pass `openai.endpoint`. " +
+        "A GEMINI_API_KEY / GOOGLE_API_KEY is also enough — qmd then uses Gemini native embeddings.",
+      );
+    }
   }
   assertCommercialEndpoint(endpoint);
 
-  const apiKey =
+  let apiKey =
     opts.openai?.apiKey ??
     env.QMD_EMBED_API_KEY ??
     cfg.embedProvider?.apiKey;
+  if (!apiKey && isGeminiEmbeddingsHost(endpoint)) {
+    apiKey = geminiKey;
+  }
 
-  const modelId =
+  let modelId =
     opts.openai?.modelId ??
     env.QMD_EMBED_MODEL_ID ??
     cfg.embedProvider?.modelId ??
     "embeddinggemma";
+  if (isGeminiEmbeddingsHost(endpoint) && modelId === "embeddinggemma") {
+    modelId = GEMINI_EMBED_MODEL;
+  }
 
   const upstreamModel =
     opts.openai?.upstreamModel ??

+ 4 - 0
src/embedding/index.ts

@@ -19,6 +19,7 @@ export {
   HttpError,
   isRetryableStatus,
   chunkArray,
+  resolveOpenAIEmbeddingIndex,
   type OpenAIProviderConfig,
   type CircuitState,
   DEFAULT_BATCH_SIZE,
@@ -32,6 +33,9 @@ export {
   assertCommercialEndpoint,
   loadConfigFile,
   defaultConfigPath,
+  isGeminiEmbeddingsHost,
+  GEMINI_OPENAI_EMBED_ENDPOINT,
+  GEMINI_EMBED_MODEL,
   type CreateEmbeddingProviderOptions,
   type EmbedProviderConfigFile,
 } from "./factory.js";

+ 26 - 15
src/embedding/openai.ts

@@ -3,7 +3,9 @@
  *
  * Talks to any endpoint that implements `POST /v1/embeddings` with the OpenAI
  * shape: request `{model, input: string|string[]}`, response
- * `{data: [{embedding: number[], index: number}, ...]}`.
+ * `{data: [{embedding: number[], index?: number}, ...]}`.
+ * `index` is optional: Gemini's OpenAI-compat layer omits it and returns
+ * embeddings in input order (session 7fcfc297). Missing index → array position.
  *
  * Used by qmd to delegate embeddings to an approved commercial HTTPS API.
  *
@@ -146,7 +148,7 @@ export type OpenAIEmbeddingsResponse = {
   model?: string;
   data: Array<{
     object?: string;
-    index: number;
+    index?: number;
     embedding: number[];
   }>;
   usage?: {
@@ -155,6 +157,21 @@ export type OpenAIEmbeddingsResponse = {
   };
 };
 
+/** Map an OpenAI-compat embedding row onto the input slot. Missing `index` = row order. */
+export function resolveOpenAIEmbeddingIndex(
+  index: unknown,
+  position: number,
+  inputCount: number,
+): number {
+  const idx = typeof index === "number" ? index : position;
+  if (!Number.isInteger(idx) || idx < 0 || idx >= inputCount) {
+    throw new Error(
+      `OpenAIEmbeddingsProvider: data item index out of range (${String(index)}, expected 0..${inputCount - 1})`,
+    );
+  }
+  return idx;
+}
+
 /**
  * Circuit breaker state — exported for tests
  */
@@ -1009,24 +1026,18 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
         );
       }
 
-      // Sort by index to match input order (in case server returns out-of-order).
+      // Prefer `index` when present (out-of-order servers). Gemini's
+      // OpenAI-compat layer omits `index` and returns rows in input order.
       const out: number[][] = new Array(texts.length);
-      for (const item of parsed.data) {
-        if (
-          typeof item.index !== "number" ||
-          item.index < 0 ||
-          item.index >= texts.length
-        ) {
-          throw new Error(
-            `OpenAIEmbeddingsProvider: data item index out of range (${item.index}, expected 0..${texts.length - 1})`,
-          );
-        }
+      for (let i = 0; i < parsed.data.length; i++) {
+        const item = parsed.data[i]!;
+        const idx = resolveOpenAIEmbeddingIndex(item.index, i, texts.length);
         if (!Array.isArray(item.embedding)) {
           throw new Error(
-            `OpenAIEmbeddingsProvider: data[${item.index}].embedding is not an array`,
+            `OpenAIEmbeddingsProvider: data[${idx}].embedding is not an array`,
           );
         }
-        out[item.index] = item.embedding;
+        out[idx] = item.embedding;
       }
 
       // Sanity check — every slot must be filled

+ 21 - 0
test/embedding-factory.test.ts

@@ -122,6 +122,27 @@ describe("resolveProviderKind", () => {
 // ─────────────────────────── createEmbeddingProvider ─────────────────────────
 
 describe("createEmbeddingProvider", () => {
+  test("native GEMINI_API_KEY with no endpoint uses Gemini OpenAI-compat", () => {
+    const p = createEmbeddingProvider({
+      env: { GEMINI_API_KEY: "AIzaSy-test-key" },
+      configPath,
+    }) as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
+    expect(p).toBeInstanceOf(OpenAIEmbeddingsProvider);
+    expect(p["endpoint"]).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(p["apiKey"]).toBe("AIzaSy-test-key");
+    expect(p.getModelId()).toBe("gemini-embedding-001");
+  });
+
+  test("GEMINI_API_KEY is not sent to a non-Gemini endpoint", () => {
+    const p = createEmbeddingProvider({
+      env: { QMD_EMBED_ENDPOINT: "https://ai.example.com", GEMINI_API_KEY: "AIzaSy-test-key" },
+      configPath,
+    }) as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
+    expect(p["endpoint"]).toBe("https://ai.example.com");
+    expect(p["apiKey"]).toBeUndefined();
+    expect(p.getModelId()).toBe("embeddinggemma");
+  });
+
   test("openai kind w/ endpoint env → OpenAIEmbeddingsProvider", () => {
     const p = createEmbeddingProvider({
       env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" },

+ 22 - 0
test/embedding-openai.test.ts

@@ -513,6 +513,28 @@ describe("OpenAIEmbeddingsProvider — malformed responses", () => {
     expect(r).toBeNull();
   });
 
+  test("missing index uses row order (Gemini OpenAI-compat)", async () => {
+    const { fetchImpl } = makeFetchSequence([
+      () =>
+        mockResponse(200, {
+          object: "list",
+          model: "gemini-embedding-001",
+          data: [
+            { object: "embedding", embedding: [0.1, 0.2] },
+            { object: "embedding", embedding: [0.7, 0.8] },
+          ],
+        }),
+    ]);
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://generativelanguage.googleapis.com/v1beta/openai",
+      fetchImpl,
+    });
+    const result = await p.embedBatch(["zero", "one"]);
+    expect(result.length).toBe(2);
+    expect(result[0]!.embedding).toEqual([0.1, 0.2]);
+    expect(result[1]!.embedding).toEqual([0.7, 0.8]);
+  });
+
   test("response handles out-of-order data array (sorts by index)", async () => {
     const { fetchImpl } = makeFetchSequence([
       () =>