Skip to content

Commit 2480ff9

Browse files
authored
ADFA-6054 | Route embeddings to selected backend and support batch indexing (#104)
* feat(ai): route embeddings to the backend the user selected Implement EmbeddingBackend in ai-agent-openai (/v1/embeddings, reordered by index) and ai-agent-gemini (batchEmbedContents, split at the per-call cap); ai-core resolves it as a chat turn does. Vector-Search drops the lexical fallback, batches, and stamps provenance so a change reindexes (schema v2). Refs: ADFA-6054 * fix(ai): address review on embedding indexing Scope the vector index per project and restore it from the database, bound the query embedding to the search budget, and report embedding failures as failures. * fix(ai-core): persist the shown backend once it is configured
1 parent 703c03b commit 2480ff9

60 files changed

Lines changed: 2994 additions & 534 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎plugins/AI-Agent-Gemini/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ Every source file sits in a package named for its layer; nothing is loose at the
5959
root of `com/itsaky/androidide/plugins/aiagentgemini/`.
6060

6161
- `plugin/GeminiPlugin.kt` — plugin entry point; registers the backend with ai-core
62-
- `backend/GeminiBackend.kt` — the REST transport, streaming (SSE) and model catalog
62+
- `backend/GeminiBackend.kt` — the REST transport, streaming (SSE), embeddings and model catalog
63+
- `backend/GeminiEmbeddingProtocol.kt` — the `batchEmbedContents` body, per-call cap and positional reply (pure)
6364
- `errors/GeminiErrorFormatter.kt` — turns an API failure into one translated sentence
6465
- `security/SecureApiKeyStore.kt` — this plugin's Keystore alias, over the IDE's `KeystoreSecretStore`
6566
- `preferences/GeminiPreferences.kt` — this plugin's settings store, plus the

‎plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
26.35 would name a release that cannot run this plugin. Do not lower it. -->
4545
<meta-data
4646
android:name="plugin.min_ide_version"
47-
android:value="26.36" />
47+
android:value="26.39" />
4848

4949
<meta-data
5050
android:name="plugin.max_ide_version"

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt‎

Lines changed: 198 additions & 32 deletions
Large diffs are not rendered by default.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.itsaky.androidide.plugins.aiagentgemini.backend
2+
3+
import java.io.IOException
4+
import org.json.JSONArray
5+
import org.json.JSONObject
6+
7+
/**
8+
* The `models/{model}:batchEmbedContents` wire format, as pure functions.
9+
*
10+
* Kept apart from [GeminiBackend] so the two rules that are easy to get silently wrong are
11+
* unit-testable without a key: the API answers **positionally**, with no `index` field of the kind
12+
* OpenAI sends, and it caps how many requests one call may carry.
13+
*/
14+
internal object GeminiEmbeddingProtocol {
15+
16+
/**
17+
* Requests per call, as the API's documented cap.
18+
*
19+
* A call over the cap is rejected whole, so a batch the caller asked for is split here rather
20+
* than handed to the server and hoped for.
21+
*/
22+
const val MAX_REQUESTS_PER_CALL = 100
23+
24+
/**
25+
* Splits [texts] into calls of at most [MAX_REQUESTS_PER_CALL], preserving order.
26+
*
27+
* @param texts the batch as the caller asked for it
28+
* @return the calls to issue, in order; empty when [texts] is empty
29+
*/
30+
fun batches(texts: List<String>): List<List<String>> = texts.chunked(MAX_REQUESTS_PER_CALL)
31+
32+
/**
33+
* Builds the request body for one call.
34+
*
35+
* Each entry repeats the model because the API requires it per request, even though every
36+
* request in a call must name the same one.
37+
*
38+
* @param model the embedding model id, without the `models/` prefix
39+
* @param texts the call's texts, in order
40+
* @return the request JSON
41+
*/
42+
fun body(model: String, texts: List<String>): JSONObject {
43+
val requests = JSONArray()
44+
for (text in texts) {
45+
requests.put(
46+
JSONObject()
47+
.put("model", "models/$model")
48+
.put(
49+
"content",
50+
JSONObject().put("parts", JSONArray().put(JSONObject().put("text", text)))
51+
)
52+
)
53+
}
54+
return JSONObject().put("requests", requests)
55+
}
56+
57+
/**
58+
* Reads a response back into the caller's order.
59+
*
60+
* The answer carries no index, so position *is* the contract: a response of the wrong length
61+
* cannot be realigned and has to fail here, rather than shift every later vector onto the
62+
* wrong chunk.
63+
*
64+
* @param response the parsed response body
65+
* @param expected how many texts the call asked for
66+
* @return the vectors, in request order
67+
* @throws IOException when the answer is the wrong length or carries an unusable vector
68+
*/
69+
fun vectors(response: JSONObject, expected: Int): List<FloatArray> {
70+
val embeddings = response.optJSONArray("embeddings")
71+
?: throw IOException("Embeddings response carried no embeddings array")
72+
if (embeddings.length() != expected) {
73+
throw IOException(
74+
"Embeddings response carried ${embeddings.length()} vectors for $expected inputs"
75+
)
76+
}
77+
78+
return (0 until expected).map { position ->
79+
val values = embeddings.optJSONObject(position)?.optJSONArray("values")
80+
if (values == null || values.length() == 0) {
81+
throw IOException("Embeddings response element $position carried no vector")
82+
}
83+
FloatArray(values.length()) { index -> values.getDouble(index).toFloat() }
84+
}
85+
}
86+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.itsaky.androidide.plugins.aiagentgemini.backend
2+
3+
/**
4+
* One key's catalog, split into the models each picker may offer.
5+
*
6+
* Carried as one value because both halves come from one paginated `ListModels` walk, filtered on
7+
* the capability each model declares: fetching them separately would pay for the walk twice and
8+
* let the two pickers describe different snapshots.
9+
*
10+
* @param chat models that advertise `generateContent`
11+
* @param embedding models that advertise `embedContent`
12+
*/
13+
internal data class ModelCatalog(
14+
val chat: List<String>,
15+
val embedding: List<String>,
16+
) {
17+
companion object {
18+
/** What a key that lists nothing offers. */
19+
val EMPTY = ModelCatalog(emptyList(), emptyList())
20+
}
21+
}

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/NetworkTags.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ internal object NetworkTags {
1414

1515
/** The ListModels catalog — `"GECT"`. */
1616
const val CATALOG = 0x47454354
17+
18+
/** Batch embedding — `"GEEM"`. */
19+
const val EMBEDDING = 0x4745454D
1720
}
1821

1922
/**

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
5454
// Tags for the controls on this backend's settings pane (see GeminiSettingsFragment).
5555
const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_gemini_key"
5656
const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_gemini_model"
57+
const val TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL = "ai_gemini_embedding_model"
5758
const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_gemini_get_free_key"
5859

5960
@Volatile
@@ -268,6 +269,22 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
268269
current models is shown instead.</p>
269270
""".trimIndent(),
270271
),
272+
PluginTooltipEntry(
273+
tag = TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL,
274+
summary = "Which model turns your code into vectors for semantic search. Never used for chat.",
275+
detail = """
276+
<p>Semantic search compares meaning rather than words, which it
277+
does by embedding every chunk of the project with this model. It
278+
is a separate setting because no Gemini model does both: this list
279+
holds the models that advertise <code>embedContent</code>, and the
280+
<b>Gemini Model</b> list above holds those that advertise
281+
<code>generateContent</code>.</p>
282+
<p>Changing it changes the vector space, so the project is indexed
283+
again from scratch. Vectors from two different models are not
284+
comparable, and mixing them would quietly return worse results
285+
rather than fail.</p>
286+
""".trimIndent(),
287+
),
271288
PluginTooltipEntry(
272289
tag = TOOLTIP_TAG_SETTINGS_GET_KEY,
273290
summary = "Opens Google AI Studio in your browser, where API keys are free to create.",

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ internal object GeminiPreferences {
2323
const val KEY_API_KEY_VERIFIED = "gemini_api_key_verified"
2424
const val KEY_MODEL = "gemini_model"
2525

26+
/**
27+
* Embedding model id, e.g. `gemini-embedding-001`.
28+
*
29+
* Separate from [KEY_MODEL] because no Gemini model advertises both `generateContent` and
30+
* `embedContent`, so one setting could never serve both.
31+
*/
32+
const val KEY_EMBEDDING_MODEL = "gemini_embedding_model"
33+
2634
/**
2735
* Why the last request was refused for credential reasons, or absent. Diagnostics rather than a
2836
* setting, so deliberately outside [OWNED_KEYS]: there is nothing here worth carrying over from
@@ -42,7 +50,7 @@ internal object GeminiPreferences {
4250

4351
/** Everything this backend owns; anything else in the old shared file is not ours to take. */
4452
private val OWNED_KEYS = listOf(
45-
KEY_API_KEY, KEY_API_KEY_TIMESTAMP, KEY_API_KEY_VERIFIED, KEY_MODEL,
53+
KEY_API_KEY, KEY_API_KEY_TIMESTAMP, KEY_API_KEY_VERIFIED, KEY_MODEL, KEY_EMBEDDING_MODEL,
4654
)
4755

4856
/**

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,20 @@ package com.itsaky.androidide.plugins.aiagentgemini.settings
88
*/
99
sealed interface CatalogResult {
1010

11-
/** The backend answered. [models] may be empty, which is itself suspicious for a valid key. */
12-
data class Success(val models: List<String>) : CatalogResult
11+
/**
12+
* The backend answered. Either list may be empty, which for [models] is itself suspicious for
13+
* a valid key.
14+
*
15+
* Both halves come from one paginated walk, split by the method each model declares, so they
16+
* describe the same snapshot of the same key.
17+
*
18+
* @param models the chat-capable models the key can reach
19+
* @param embeddingModels the embedding-capable models the key can reach
20+
*/
21+
data class Success(
22+
val models: List<String>,
23+
val embeddingModels: List<String>,
24+
) : CatalogResult
1325

1426
/** No "gemini" backend was resolvable — ai-core or ai-agent-gemini is missing, disabled,
1527
* or not yet active. */

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aiagentgemini.settings
22

33
import com.itsaky.androidide.plugins.PluginLogger
44
import com.itsaky.androidide.plugins.aiagentgemini.backend.GeminiBackend
5+
import com.itsaky.androidide.plugins.aiagentgemini.backend.ModelCatalog
56
import com.itsaky.androidide.plugins.aiagentgemini.logging.LOG_PREFIX
67
import com.itsaky.androidide.plugins.aiagentgemini.plugin.GeminiPlugin
78
import java.util.concurrent.CancellationException
@@ -63,10 +64,10 @@ class BackendGeminiCatalogGateway(
6364
get() = GeminiPlugin.getContext()?.logger
6465

6566
override fun listModelsForSavedKey(): CatalogResult =
66-
await { it.listModels() }
67+
await { it.listCatalog() }
6768

6869
override fun listModels(apiKey: String): CatalogResult =
69-
await { it.listModels(apiKey) }
70+
await { it.listCatalog(apiKey) }
7071

7172
/**
7273
* Runs [request] against the backend and awaits its future.
@@ -76,7 +77,7 @@ class BackendGeminiCatalogGateway(
7677
* @param request the catalog call to make; picks which credential is used
7778
*/
7879
private fun await(
79-
request: (GeminiBackend) -> CompletableFuture<List<String>>
80+
request: (GeminiBackend) -> CompletableFuture<ModelCatalog>
8081
): CatalogResult {
8182
val backend = try {
8283
backendProvider()
@@ -93,7 +94,9 @@ class BackendGeminiCatalogGateway(
9394
}
9495

9596
return try {
96-
CatalogResult.Success(future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS).orEmpty())
97+
val catalog = future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
98+
?: ModelCatalog.EMPTY
99+
CatalogResult.Success(catalog.chat, catalog.embedding)
97100
} catch (e: ExecutionException) {
98101
// The API failure the backend reported; its message carries the HTTP status.
99102
CatalogResult.Failed(e.cause ?: e)

0 commit comments

Comments
 (0)