From 7419303fbeef05d63389198564c81aead400ce70 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 12 Aug 2026 11:52:13 -0500 Subject: [PATCH] feat(ai): add ai-agent-openai, an OpenAI-compatible backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers the `openai` backend with ai-core: POST {baseUrl}/chat/completions over HttpURLConnection, with the base URL as a setting defaulting to OpenAI. One backend covers OpenAI, Ollama, LM Studio, OpenRouter and llama-server, since only the host differs between them (ADFA-3017, ADFA-3452). The API key is optional unless the URL is OpenAI's own host, the model is a free-text field, and /v1/models is treated as optional — the three things that would each make the backend unusable for a self-hosted server. Reasoning-model parameters are chosen from the model id and a refused one is retried without. No changes to ai-core. --- README.md | 1 + ai-agent-openai/.gitignore | 3 + ai-agent-openai/README.md | 143 ++++ ai-agent-openai/ai-agent-openai.html | 170 ++++ ai-agent-openai/build.gradle.kts | 82 ++ ai-agent-openai/gradle.properties | 10 + ai-agent-openai/proguard-rules.pro | 21 + ai-agent-openai/settings.gradle.kts | 35 + ai-agent-openai/src/main/AndroidManifest.xml | 67 ++ .../src/main/assets/docs/index.html | 153 ++++ ai-agent-openai/src/main/assets/icon_day.png | Bin 0 -> 11404 bytes .../src/main/assets/icon_night.png | Bin 0 -> 10800 bytes .../aiagentopenai/backend/ChatModelFilter.kt | 49 ++ .../aiagentopenai/backend/OpenAiBackend.kt | 579 +++++++++++++ .../aiagentopenai/backend/OpenAiHttpClient.kt | 108 +++ .../backend/OpenAiRequestBuilder.kt | 93 ++ .../aiagentopenai/backend/RequestTuning.kt | 131 +++ .../plugins/aiagentopenai/backend/SseChunk.kt | 147 ++++ .../errors/OpenAiErrorFormatter.kt | 218 +++++ .../errors/OpenAiFailureMessages.kt | 79 ++ .../errors/OpenAiHttpException.kt | 19 + .../plugins/aiagentopenai/logging/LogTags.kt | 8 + .../aiagentopenai/plugin/OpenAiPlugin.kt | 324 +++++++ .../preferences/OpenAiPreferences.kt | 65 ++ .../prompt/OpenAiSystemPrompt.kt | 100 +++ .../aiagentopenai/security/ApiKeyCache.kt | 99 +++ .../security/SecureApiKeyStore.kt | 143 ++++ .../aiagentopenai/settings/BaseUrlPolicy.kt | 213 +++++ .../aiagentopenai/settings/CatalogResult.kt | 22 + .../settings/ConnectionVerification.kt | 125 +++ .../aiagentopenai/settings/ModelSelection.kt | 33 + .../settings/OpenAiCatalogGateway.kt | 119 +++ .../settings/OpenAiKeyOnboarding.kt | 19 + .../settings/OpenAiSettingsFragment.kt | 802 ++++++++++++++++++ .../settings/OpenAiSettingsViewModel.kt | 371 ++++++++ .../settings/RememberedModels.kt | 46 + .../aiagentopenai/settings/ServerPreset.kt | 54 ++ .../src/main/res/drawable/ic_dropdown.xml | 10 + .../src/main/res/drawable/ic_key_rejected.xml | 10 + .../main/res/drawable/ic_key_unchecked.xml | 10 + .../src/main/res/drawable/ic_key_verified.xml | 10 + .../src/main/res/drawable/ic_visibility.xml | 10 + .../main/res/drawable/ic_visibility_off.xml | 10 + .../res/layout/fragment_openai_settings.xml | 225 +++++ .../src/main/res/values-night/colors.xml | 36 + .../src/main/res/values/colors.xml | 42 + .../src/main/res/values/strings.xml | 99 +++ .../src/main/res/values/styles.xml | 38 + .../backend/ChatModelFilterTest.kt | 75 ++ .../backend/OpenAiRequestBuilderTest.kt | 160 ++++ .../backend/RequestTuningTest.kt | 165 ++++ .../aiagentopenai/backend/SseChunkTest.kt | 151 ++++ .../errors/OpenAiErrorFormatterTest.kt | 146 ++++ .../prompt/OpenAiSystemPromptTest.kt | 80 ++ .../settings/BaseUrlPolicyTest.kt | 211 +++++ .../settings/ConnectionVerificationTest.kt | 158 ++++ .../settings/ModelSelectionTest.kt | 58 ++ .../settings/RememberedModelsTest.kt | 52 ++ 58 files changed, 6407 insertions(+) create mode 100644 ai-agent-openai/.gitignore create mode 100644 ai-agent-openai/README.md create mode 100644 ai-agent-openai/ai-agent-openai.html create mode 100644 ai-agent-openai/build.gradle.kts create mode 100644 ai-agent-openai/gradle.properties create mode 100644 ai-agent-openai/proguard-rules.pro create mode 100644 ai-agent-openai/settings.gradle.kts create mode 100644 ai-agent-openai/src/main/AndroidManifest.xml create mode 100644 ai-agent-openai/src/main/assets/docs/index.html create mode 100644 ai-agent-openai/src/main/assets/icon_day.png create mode 100644 ai-agent-openai/src/main/assets/icon_night.png create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ChatModelFilter.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/SseChunk.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiHttpException.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/logging/LogTags.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/preferences/OpenAiPreferences.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/ApiKeyCache.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/CatalogResult.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ConnectionVerification.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ModelSelection.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiCatalogGateway.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiKeyOnboarding.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsViewModel.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/RememberedModels.kt create mode 100644 ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ServerPreset.kt create mode 100644 ai-agent-openai/src/main/res/drawable/ic_dropdown.xml create mode 100644 ai-agent-openai/src/main/res/drawable/ic_key_rejected.xml create mode 100644 ai-agent-openai/src/main/res/drawable/ic_key_unchecked.xml create mode 100644 ai-agent-openai/src/main/res/drawable/ic_key_verified.xml create mode 100644 ai-agent-openai/src/main/res/drawable/ic_visibility.xml create mode 100644 ai-agent-openai/src/main/res/drawable/ic_visibility_off.xml create mode 100644 ai-agent-openai/src/main/res/layout/fragment_openai_settings.xml create mode 100644 ai-agent-openai/src/main/res/values-night/colors.xml create mode 100644 ai-agent-openai/src/main/res/values/colors.xml create mode 100644 ai-agent-openai/src/main/res/values/strings.xml create mode 100644 ai-agent-openai/src/main/res/values/styles.xml create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ChatModelFilterTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilderTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuningTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/SseChunkTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatterTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPromptTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ConnectionVerificationTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ModelSelectionTest.kt create mode 100644 ai-agent-openai/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/RememberedModelsTest.kt diff --git a/README.md b/README.md index 2dd2a5d2..559ed093 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`ai-core/`](ai-core/) | The **Agent** chat (tool-calling assistant) plus the shared LLM inference **router** other plugins consume. Ships no model — install at least one backend plugin below. Mandatory for every AI feature. | | [`ai-agent-local/`](ai-agent-local/) | On-device `.gguf` inference backend for `ai-core` (bundled llama.cpp AAR). Registers as `local`; needs no network. | | [`ai-agent-gemini/`](ai-agent-gemini/) | Google Gemini API inference backend for `ai-core`. Registers as `gemini`; needs an API key and network access. | +| [`ai-agent-openai/`](ai-agent-openai/) | OpenAI-compatible inference backend for `ai-core`. Registers as `openai`; talks to OpenAI by default, or to Ollama / LM Studio / OpenRouter / `llama-server` by changing one URL. | | [`flutter-template/`](flutter-template/) | Adds Flutter starter project templates (Basic, BLoC, Provider, GetX, Riverpod) to the New Project screen. | | [`code-suggestions-plugin/`](code-suggestions-plugin/) | Inline ghost-text code completions powered by AI. | | [`speech-to-text-plugin/`](speech-to-text-plugin/) | Voice-to-code: converts speech to code with AI generation. | diff --git a/ai-agent-openai/.gitignore b/ai-agent-openai/.gitignore new file mode 100644 index 00000000..5380c6d5 --- /dev/null +++ b/ai-agent-openai/.gitignore @@ -0,0 +1,3 @@ +**/.cxx/ +build-output.log +**/.kotlin/ diff --git a/ai-agent-openai/README.md b/ai-agent-openai/README.md new file mode 100644 index 00000000..006aed4f --- /dev/null +++ b/ai-agent-openai/README.md @@ -0,0 +1,143 @@ +# AI Agent OpenAI plugin for CodeOnTheGo + +OpenAI-compatible inference for CodeOnTheGo's AI plugins. Registers itself as the +`openai` backend with [`ai-core`](../ai-core/)'s `LlmInferenceService`, which is +what `ai-core`'s Agent chat, `code-suggestions-plugin`, `speech-to-text-plugin` +and `vector-search-plugin` actually talk to. + +**One backend, many servers.** It speaks `POST {baseUrl}/chat/completions`, and +the base URL is a setting. Across OpenAI, Ollama, LM Studio, OpenRouter and +llama.cpp's `llama-server` the auth header, request JSON, SSE framing and error +shape are identical — only the host changes. So this is one backend with a URL +field rather than one plugin per provider: + +| Base URL | What it is | +|---|---| +| `https://api.openai.com/v1` | **Default.** OpenAI itself. | +| `http://localhost:11434/v1` | Ollama on the device (e.g. in the bundled Termux). | +| `http://192.168.1.50:11434/v1` | Ollama on the user's PC, over Wi-Fi. | +| `http://192.168.1.50:1234/v1` | LM Studio's server. | +| `http://localhost:8080/v1` | `llama-server` from llama.cpp. | +| `https://openrouter.ai/api/v1` | OpenRouter — many models behind one key, some free. | + +Calls the API directly over `HttpURLConnection` rather than an SDK: plugins run +in the host IDE's classloader, where `okhttp3` resolves to the host's older +OkHttp, and an SDK bundling its own copy crashes generation with a +`NoSuchMethodError`. + +## Building + +Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with +`sdk.dir=...`. This plugin uses the shared wrapper at the repo root: + +```bash +cd ai-agent-openai +../gradlew assemblePlugin # release -> build/plugin/ai-agent-openai.cgp +../gradlew assemblePluginDebug # debug variant +../gradlew testDebugUnitTest # the JVM unit tests +``` + +## Configuration + +Everything is configured in **AI Core → Agent settings**, on the pane this plugin +contributes: server URL (with presets), API key, model, and one **Test Connection +& List Models** button. Nothing outside this plugin handles the key. + +The pane adapts to the chosen server as it is picked, via +`BaseUrlPolicy.keyRequirement()`: `REQUIRED` for OpenAI's own host, `EXPECTED` for +another cloud provider, `NOT_NEEDED` for loopback or a private address — where the +whole key section is hidden rather than showing an empty, mandatory-looking field +for a server that wants no credential. Listing models and testing the connection +are the same `GET {baseUrl}/models`, so they are one control, and the model is a +single editable dropdown rather than a field beside a spinner. + +Three rules that each break a real user if got wrong, and are covered by tests: + +- **The API key is optional.** `isAvailable()` requires a key only when the base + URL is OpenAI's own host. For any other server a non-blank URL is enough — + local Ollama and LM Studio need no credential, and demanding one would leave + the backend permanently "not available" for exactly the users who wanted a + custom server. +- **The model is a field, not a constant.** Pointed at a local server the model + is whatever the user pulled (`qwen2.5-coder`, `llama3.2`), so free-text entry + always works and `GET /v1/models` is treated as optional — plenty of compatible + servers do not implement it, which is why a 404 there reports "check the URL" + rather than rejecting the key. +- **No auto-discovery.** There is no probing of `localhost:11434`; a background + port scan is not something the user asked for. The URL field already reaches + any server, on-device or on the LAN. + +### Reasoning models + +`gpt-5.x` and the `o` series reject `max_tokens` in favour of +`max_completion_tokens`, and several reject `temperature`. `RequestTuning` picks +the parameters from the model id and the server, and `UnsupportedParameter` reads +the offending name out of a 400 so the request is retried once without it — +compatible servers vary too much to hardcode a matrix. + +### Cleartext URLs + +`https` is required except for loopback and private ranges (RFC 1918, link-local, +IPv6 ULA, and bare LAN hostnames), where plain `http` is accepted and warned about +once on save. That is the "Ollama on my PC" case, and the host IDE's +`network_security_config` permits cleartext, so it works at runtime. + +## API key handling + +Stored encrypted (AES/GCM under a hardware-backed Android Keystore secret) and +sent as an `Authorization: Bearer` **header**, never in a URL query string. With +no key configured, no header is sent at all. + +`security/SecureApiKeyStore.kt` is this plugin's **own copy**, under its own +Keystore alias (`cotg_ai_openai_key_v1`). It is deliberately not shared with +ai-agent-gemini's copy: every plugin runs in the host app's process and UID and +therefore shares one Keystore, so a shared alias would let one plugin's +invalidated-key recovery (`deleteEntry`) destroy the other backend's stored key. +The two never read each other's ciphertext, so they have no reason to share an +alias — and there is therefore nothing to keep in parity. Extracting the shared +*source* is tracked separately. + +## Installation + +Install **`ai-core` as well** — without the router this plugin has nothing to +register with. Order does not matter: this plugin re-registers when it sees +ai-core activate. Copy `build/plugin/ai-agent-openai.cgp` to the device, install +via CodeOnTheGo's Plugin Manager, then restart the IDE. + +## Native function calling + +Not implemented, deliberately. This backend declares `HistoryCapableBackend` but +not `ToolCallingBackend`, so ai-core streams it the whole conversation and the +agent loop drives tools through a text envelope in the system prompt, which is +provider-agnostic. Declaring `ToolCallingBackend` without native function calling +would leave the caller waiting on a call this backend never makes. The system +prompt also tells the model not to use its own function-calling channel, since +nothing reads it. + +## Key classes + +Every source file sits in a package named for its layer; nothing is loose at the +root of `com/itsaky/androidide/plugins/aiagentopenai/`. + +- `plugin/OpenAiPlugin.kt` — plugin entry point; registers the backend with ai-core +- `backend/OpenAiBackend.kt` — the HTTP transport, SSE streaming and model catalog +- `backend/OpenAiRequestBuilder.kt` — `messages[]` mapping and request JSON (pure) +- `backend/RequestTuning.kt` — reasoning-model parameters and the 400-retry rule (pure) +- `backend/SseChunk.kt` — one line of the token stream (pure) +- `backend/ChatModelFilter.kt` — keeps non-chat models out of the picker (pure) +- `errors/OpenAiErrorFormatter.kt` — turns a failure into one translated sentence +- `security/SecureApiKeyStore.kt` — AES/GCM at rest +- `preferences/OpenAiPreferences.kt` — this plugin's settings store +- `prompt/OpenAiSystemPrompt.kt` — the system prompt this cloud model is given +- `settings/BaseUrlPolicy.kt` — URL normalization and the cleartext rule (pure) +- `settings/ServerPreset.kt` — the one-tap server list +- `settings/ConnectionVerification.kt` — what a live check established (pure) +- `settings/` — the pane this backend contributes to the selector +- `logging/` — `LOG_PREFIX` (`AiAgentOpenAi`), prefixing every logcat tag + +The pure units carry the logic that would otherwise only fail on a device; they +are covered by 118 JVM tests. + +## License + +GPL-3.0 — same as AndroidIDE / CodeOnTheGo. diff --git a/ai-agent-openai/ai-agent-openai.html b/ai-agent-openai/ai-agent-openai.html new file mode 100644 index 00000000..33bf7626 --- /dev/null +++ b/ai-agent-openai/ai-agent-openai.html @@ -0,0 +1,170 @@ + + + + + +AI Agent OpenAI Plugin + + + + +

AI Agent OpenAI Plugin

+ +

Executive overview

+

AI Agent OpenAI adds OpenAI's models to CodeOnTheGo's AI + features — and, through one editable server URL, any other server that speaks + the same protocol. It is a headless plugin with no screens of its own + beyond a settings pane: it registers itself as the openai inference + backend with AI Core, which routes requests from the Agent chat, + Code Suggestions, Speech to Text and Vector Search.

+

Install AI Core alongside it — without the router this plugin has + nothing to register with. Install order does not matter.

+

Using this backend sends prompts, and any file contents a plugin includes in + them, to whichever server is configured. Pointed at OpenAI that is a third + party; pointed at a machine on your own network it never leaves that network.

+ +

One backend, many servers

+

Across every server below the auth header, request JSON, streaming format + and error shape are identical — only the host changes. So this is one backend + with a URL field rather than one plugin per provider.

+ + + + + + + + +
Server URLWhat it is
https://api.openai.com/v1Default. OpenAI itself.
http://localhost:11434/v1Ollama running on the device.
http://192.168.1.50:11434/v1Ollama on your own PC, over Wi-Fi.
http://192.168.1.50:1234/v1LM Studio's server.
http://localhost:8080/v1llama-server from llama.cpp.
https://openrouter.ai/api/v1OpenRouter — many models behind one key, some free.
+

Presets in the settings pane fill these in. The local ones use + localhost; to reach another machine, pick the preset and edit the + host.

+ +

Core functionality

+ + +

Technical architecture

+ + + + + + + + +
ComponentRole
OpenAiPluginPlugin entry point. Registers the + backend with AI Core on activation, re-registering if AI Core activates + later; cancels in-flight requests and drops the decrypted key on + dispose.
OpenAiBackendThe transport. Calls + chat/completions over HttpURLConnection, parses the + streaming response, and fetches the model catalog.
BaseUrlPolicyNormalizes the server URL — trims a + pasted /chat/completions path, lowercases the host — and + enforces the cleartext rule.
RequestTuningDecides which optional parameters a + request carries, and which one to stop sending after a server refuses + it.
OpenAiErrorFormatterClassifies a failure + (unknown model, rate limit, spent balance, refused key, outage, server not + running) so it can be reported as one translated sentence.
SecureApiKeyStoreAES/GCM encryption of the API + key, under this plugin's own Keystore alias.
+

No third-party HTTP SDK. Plugins run in the host IDE's classloader + where okhttp3 resolves to the host's older OkHttp — a mismatch that + crashed generation with a NoSuchMethodError when an SDK bundled its + own copy. HttpURLConnection has no third-party dependency and works + regardless of the host's OkHttp version.

+ +

Usage

+
    +
  1. Install AI Core and AI Agent OpenAI via the Plugin Manager, + then restart the IDE.
  2. +
  3. Open Preferences → Configuration → Agent and select + OpenAI as the backend.
  4. +
  5. Set the Server: keep the OpenAI default, or pick a preset and edit + the host to reach your own machine.
  6. +
  7. Enter an API key if the server needs one — it is checked before it + is saved. For a local server the section is hidden; there is nothing to + enter.
  8. +
  9. Tap Test Connection & List Models, then set the Model by + picking from that list or typing a name.
  10. +
+
+ The key is sent as an Authorization: Bearer request header, never + in a URL query string — query strings leak into logs, proxies and crash + reports. With no key configured, no header is sent at all. +
+ +

Cost

+

OpenAI has no free tier: an API key draws on a prepaid balance and is + separate from a ChatGPT subscription. The free paths are to run a model on your + own computer and point the server URL at it, to use AI Agent Local for + on-device inference, to use AI Agent Gemini, which has a free tier, or to + point this backend at one of OpenRouter's free models.

+ +

Key benefits

+ + + diff --git a/ai-agent-openai/build.gradle.kts b/ai-agent-openai/build.gradle.kts new file mode 100644 index 00000000..9fa1882e --- /dev/null +++ b/ai-agent-openai/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "ai-agent-openai" +} + +android { + namespace = "com.itsaky.androidide.plugins.aiagentopenai" + compileSdk = 36 + + defaultConfig { + applicationId = "com.itsaky.androidide.plugins.aiagentopenai" + minSdk = 33 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + isShrinkResources = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + + packaging { + resources { + excludes += setOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/INDEX.LIST" + ) + } + } +} + +dependencies { + compileOnly(files("../libs/plugin-api.jar")) + + // 'implementation' (not 'compileOnly') for the androidx/Material libraries: AAPT2 needs them + // at compile time to process the settings pane's layout, as in every CoGo plugin with XML. + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("com.google.android.material:material:1.10.0") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + testImplementation(files("../libs/plugin-api.jar")) + testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk:1.13.8") + testImplementation("org.json:json:20231013") +} + +// This plugin carries its own copy of SecureApiKeyStore under its own Keystore alias. No parity +// check against ai-agent-gemini's copy: the two never read each other's ciphertext, and a shared +// alias would let one plugin's invalidation recovery delete the other plugin's key. + +// AAR metadata checks are disabled by convention for these application-as-library plugins. +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { enabled = false } diff --git a/ai-agent-openai/gradle.properties b/ai-agent-openai/gradle.properties new file mode 100644 index 00000000..fcd58cda --- /dev/null +++ b/ai-agent-openai/gradle.properties @@ -0,0 +1,10 @@ +android.enableJetifier=false +android.jetifier.ignorelist=common-30.2.2.jar +android.nonTransitiveRClass=false +android.useAndroidX=true +org.gradle.caching=true +org.gradle.configureondemand=true +org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" +org.gradle.parallel=true + +kotlin.code.style=official diff --git a/ai-agent-openai/proguard-rules.pro b/ai-agent-openai/proguard-rules.pro new file mode 100644 index 00000000..d0ca008b --- /dev/null +++ b/ai-agent-openai/proguard-rules.pro @@ -0,0 +1,21 @@ +# AI Agent OpenAI Plugin ProGuard Rules + +# Keep plugin entry point +-keep public class com.itsaky.androidide.plugins.aiagentopenai.plugin.OpenAiPlugin { + public ; +} + +# Keep the backend: its settings pane resolves it through OpenAiPlugin.getBackend() to list +# models and test a connection, and AI Core reaches it across the plugin classloader boundary. +-keep public class com.itsaky.androidide.plugins.aiagentopenai.backend.OpenAiBackend { + public ; +} + +# Keep the settings pane: it is named to the host as a string by +# OpenAiBackend.getSettingsFragmentClassName() and instantiated reflectively. +-keep public class com.itsaky.androidide.plugins.aiagentopenai.settings.OpenAiSettingsFragment { + public ; +} + +# Keep plugin-api interfaces +-keep interface com.itsaky.androidide.plugins.** { *; } diff --git a/ai-agent-openai/settings.gradle.kts b/ai-agent-openai/settings.gradle.kts new file mode 100644 index 00000000..63f980bb --- /dev/null +++ b/ai-agent-openai/settings.gradle.kts @@ -0,0 +1,35 @@ +@file:Suppress("UnstableApiUsage") + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath(files("../libs/plugin-api.jar")) + classpath(files("../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "ai-agent-openai" diff --git a/ai-agent-openai/src/main/AndroidManifest.xml b/ai-agent-openai/src/main/AndroidManifest.xml new file mode 100644 index 00000000..aefa3899 --- /dev/null +++ b/ai-agent-openai/src/main/AndroidManifest.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai-agent-openai/src/main/assets/docs/index.html b/ai-agent-openai/src/main/assets/docs/index.html new file mode 100644 index 00000000..d8a068f0 --- /dev/null +++ b/ai-agent-openai/src/main/assets/docs/index.html @@ -0,0 +1,153 @@ + + + + + +AI Agent OpenAI — Guide + + + +

AI Agent OpenAI — Cloud and Self-Hosted Inference

+ +

AI Agent OpenAI is a headless plugin: it has no screens of its own, + only a settings pane inside AI Core. It adds the openai backend to + AI Core, which routes requests from the Agent chat, Code + Suggestions, Speech to Text and Vector Search. Install + AI Core as well — without it this plugin has nothing to register with.

+ +

One backend, many servers

+ +

It speaks the OpenAI chat/completions protocol. Every server + below speaks the same protocol, so only the URL changes — set it in + Agent settings after selecting OpenAI as the backend.

+ + + + + + + + + +
Server URLWhat it is
https://api.openai.com/v1OpenAI itself. The default.
http://localhost:11434/v1Ollama running on this device.
http://192.168.1.50:11434/v1Ollama on your own PC, over Wi-Fi.
http://192.168.1.50:1234/v1LM Studio's server.
http://localhost:8080/v1llama-server from llama.cpp.
https://openrouter.ai/api/v1OpenRouter — many models behind one key, some free.
+ +

The presets in the settings pane fill these in for you. The local ones use + localhost; to reach another machine, pick the preset and edit the + host to that machine's address.

+ +

Setup

+
    +
  • Server — pick a preset or type a URL, then tap Save Server. + It must end at the API root (…/v1), not at + /chat/completions. If you paste the full endpoint URL, the + extra path is removed for you.
  • +
  • API key — required for OpenAI and OpenRouter, and left blank for a + local server, which needs no credential. The key is checked against the + server before it is saved. This section disappears entirely when the + server URL points at your own device or network — there is no key to enter, + so the pane stops asking for one.
  • +
  • Model — one field that is also a list: type any model name, or tap + it to pick one the server reported. Typing is saved when you leave the + field. For a local server the model is whatever you pulled, such as + qwen2.5-coder.
  • +
  • Test Connection & List Models — checks the URL and key together + without saving either, so a typo is caught here rather than mid-chat. The + same reply fills the model list, because listing models is the + connection test.
  • +
+ +
+ The key is stored encrypted with AES/GCM under a hardware-backed Android + Keystore secret, and is sent as an Authorization: Bearer header + — never in a URL, where it would leak into logs and proxies. With no key + configured, no authorization header is sent at all. +
+ +

Cost, and the free options

+ +

OpenAI has no free tier. An API key draws on a prepaid balance and is + separate from a ChatGPT subscription — paying for ChatGPT does not give + you API access. If you want to use the Agent without paying:

+
    +
  • Run a model on your own computer and point the server URL at it. This is + free, and your code never leaves your network.
  • +
  • Use AI Agent Local to run a small model on the device itself.
  • +
  • Use AI Agent Gemini, which has a free tier.
  • +
  • Point this backend at OpenRouter, which offers some free models.
  • +
+ +

Privacy

+

Prompts, and any file contents a plugin includes in them, are transmitted to + whichever server you configure. For OpenAI or OpenRouter that means your code + leaves the device to a third party. For a server on your own machine it stays + on your network.

+ +

Plain http:// is accepted only for this device or a private + network address, and the pane warns you once when you save one: that traffic + crosses your local network unencrypted. An unencrypted address on the open + internet is refused outright.

+ +

Reasoning models

+

Reasoning models (the gpt-5 and o series) take + different request parameters: they reject max_tokens in favour of + max_completion_tokens, and several reject + temperature. This is handled for you — the right parameters are + chosen from the model name, and if a server still refuses one, the request is + retried once without it.

+ +

Troubleshooting

+
    +
  • "The server answered 404" — the URL is almost certainly missing + its /v1 suffix.
  • +
  • "Nothing answered" — the server is not running, or this device + cannot reach it. Check that Ollama or LM Studio is started, and that the + phone is on the same network as the computer.
  • +
  • "The server refused your API key" — the key is wrong or revoked; + re-enter it.
  • +
  • "Your OpenAI account has no credit left" — API usage is prepaid. + Add credit, or use one of the free options above.
  • +
  • "The server answered but has no models loaded" — for Ollama, pull + a model first (ollama pull qwen2.5-coder).
  • +
  • "The server does not have a model called …" — tap Test + Connection & List Models, then pick from the Model field, or type the + exact name the server uses.
  • +
  • The OpenAI backend never appears — AI Core isn't + installed or activated; install it and restart the IDE.
  • +
+ + diff --git a/ai-agent-openai/src/main/assets/icon_day.png b/ai-agent-openai/src/main/assets/icon_day.png new file mode 100644 index 0000000000000000000000000000000000000000..029e044efac51d3cfd5d3182d7bbc45ac4f868b8 GIT binary patch literal 11404 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

AI Agent OpenAI is a headless plugin that adds the + openai backend to AI Core, calling + chat/completions over HTTP.

+

It defaults to OpenAI's own API, but the server URL is a + setting — point it at Ollama or LM Studio on your PC, at a + llama-server, or at OpenRouter, and the same backend + talks to all of them.

+

Install AI Core as well, then configure the server in + AI Core → Agent settings. Prompts and any file contents a + plugin sends are transmitted to whichever server you configure.

+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "AI Agent OpenAI guide", + uri = "index.html", + order = 0 + ) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_SERVER, + summary = "The server to send prompts to. Defaults to OpenAI; change it to use your own.", + detail = """ +

Must end at the API root — https://api.openai.com/v1, + not the /chat/completions path. If you paste the full + endpoint URL, the extra path is removed for you.

+

Plain http:// is accepted only for your own device + or a private network address, which is the Ollama-on-my-PC case. A + cleartext address on the open internet is refused, because your + project's source would travel unencrypted.

+ """.trimIndent(), + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_PRESET, + summary = "Fills the server URL for a known server. Nothing is saved until you tap Save.", + detail = """ +

Each preset is only a URL: OpenAI, Ollama, LM Studio, + llama-server and OpenRouter all speak the same + protocol, so one backend reaches all of them.

+

The local presets use localhost. To reach a server + on another machine, pick the preset and then edit the host — for + example http://192.168.1.50:11434/v1.

+ """.trimIndent(), + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_KEY, + summary = "Your API key. Optional — a local Ollama or LM Studio server needs none.", + detail = """ +

Required for OpenAI itself and for OpenRouter; left blank for a + local server, where no credential is sent at all. This whole + section disappears when the server URL points at your own device or + network, because there is no key to enter.

+

The key is checked against the server before being saved, then + encrypted with the Android Keystore — only the ciphertext is + written to disk. A key that cannot be checked, because the server + is offline, can still be saved but is marked unverified rather + than claiming a check that never happened.

+ """.trimIndent(), + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_MODEL, + summary = "Which model to request. Type any name, or tap to pick one the server reported.", + detail = """ +

One field, and it accepts both: type a name, or tap it to choose + from the list Test Connection & List Models fetched. + Typing is saved as soon as you leave the field.

+

Free text always works, which matters for a local server: the + model is whatever you pulled, such as + qwen2.5-coder. A server that does not implement a + model list is normal — just type the name.

+

Unlike Google's catalog, this list carries no "can chat" flag, + so obvious non-chat models (embeddings, audio, images) are filtered + out and anything unrecognised is kept.

+ """.trimIndent(), + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_TEST, + summary = "Checks the server and key, and fills the model list — both are the same request.", + detail = """ +

Tests the URL and the key together, without saving either, so a + typo is caught here rather than mid-chat. When the server answers + with a catalog, that same answer fills the Model list.

+

404 almost always means the URL is missing its + /v1 suffix. Nothing answered means the server + is not running or is not reachable from this device — check that + Ollama is started and that the phone is on the same network.

+ """.trimIndent(), + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_GET_KEY, + summary = "Opens OpenAI's API keys page in your browser. OpenAI keys are not free.", + detail = """ +

Opens platform.openai.com/api-keys in your own + browser — never an embedded WebView, so you can see OpenAI's URL + bar and sign-in works. Sign in, create a key, copy it, and paste + it into the field here.

+

OpenAI has no free tier: an API key needs a prepaid balance, + separate from a ChatGPT subscription. For a free option, run a + model on your own machine and point the server URL at it, or use + the AI Agent Local or AI Agent Gemini plugin + instead.

+

This plugin never sees your password and never reads your + clipboard.

+ """.trimIndent(), + ), + ) + + override fun getTier3DocsAssetPath(): String = "docs" +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/preferences/OpenAiPreferences.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/preferences/OpenAiPreferences.kt new file mode 100644 index 00000000..9635d29c --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/preferences/OpenAiPreferences.kt @@ -0,0 +1,65 @@ +package com.itsaky.androidide.plugins.aiagentopenai.preferences + +import android.content.SharedPreferences +import com.itsaky.androidide.plugins.PluginContext + +/** + * This plugin's own settings store. + * + * The base URL, API key and model describe *this* backend, so they live in this plugin's storage + * rather than in AI Core's — a backend must be configurable whether or not any particular consumer + * plugin happens to be installed. + * + * There is no migration from an older file: this backend has never shipped before, so there is + * nothing on any device to adopt. + */ +internal object OpenAiPreferences { + + /** This plugin's preferences file. Namespaced to this plugin by the host. */ + private const val FILE = "OpenAiSettings" + + /** Server base URL, e.g. `https://api.openai.com/v1`. Stored normalized. */ + const val KEY_BASE_URL = "openai_base_url" + + /** API key, stored as ciphertext only. Optional for a server that needs none. */ + const val KEY_API_KEY = "openai_api_key" + + const val KEY_API_KEY_TIMESTAMP = "openai_api_key_timestamp" + const val KEY_API_KEY_VERIFIED = "openai_api_key_verified" + + /** Model id to request, e.g. `gpt-5` or `qwen2.5-coder`. */ + const val KEY_MODEL = "openai_model" + + /** + * The base URL [KEY_MODEL] was chosen for. + * + * Stored alongside so switching servers can tell a model this server offers from one carried + * over from the last server, which is what would 404 on the first message. + */ + const val KEY_MODEL_URL = "openai_model_url" + + /** Set once the user has been warned about a cleartext URL, so the warning shows once. */ + const val KEY_CLEARTEXT_ACKNOWLEDGED = "openai_cleartext_acknowledged" + + /** + * The last model list a server returned, so reopening the settings pane offers the dropdown + * without another request. Encoded by `RememberedModels`. + */ + const val KEY_REMEMBERED_MODELS = "openai_remembered_models" + + /** + * The base URL [KEY_REMEMBERED_MODELS] was fetched from. + * + * Stored alongside so a list remembered from LM Studio is never offered after the URL is + * pointed at OpenAI — the two catalogs have nothing in common. + */ + const val KEY_REMEMBERED_MODELS_URL = "openai_remembered_models_url" + + /** + * This plugin's preferences. + * + * @param context this plugin's own context — never another plugin's + */ + fun of(context: PluginContext): SharedPreferences = + context.getPluginSharedPreferences(FILE) +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt new file mode 100644 index 00000000..5b287fa4 --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt @@ -0,0 +1,100 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt + +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest + +/** + * The system prompt this backend asks for. + * + * Written for a large cloud model: it states a goal and a workflow and trusts the model to plan + * within them, where a small on-device model needs each step spelled out. That difference is a + * property of the model, so the prompt lives with the backend that talks to it. + * + * Model-facing text, so it stays in Kotlin rather than `strings.xml` — it is never shown to the + * user, must not be translated, and is asserted on in unit tests. + * + * Pure and free of Android types, so it is unit-testable without a device or a network. + */ +internal object OpenAiSystemPrompt { + + /** + * Path used in the examples when the caller names none, so they still show a concrete shape. + */ + private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" + + /** + * Builds the prompt for [request]. + * + * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce + * replies nothing reads — and a null one means the caller parses no envelope, so the format + * section and its examples are left out rather than taught in a syntax nothing reads back. + * + * @return the system prompt, without the caller's IDE-context block + */ + fun build(request: SystemPromptRequest): String { + val toolDescriptions = request.tools.joinToString("\n") { "- ${it.name}: ${it.description}" } + val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH + val exampleStem = examplePath.substringAfterLast('/').substringBeforeLast('.') + + val head = """ + You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions. + + AVAILABLE TOOLS: + $toolDescriptions + + BEHAVIOR: + - Create complete, production-ready code + - Call tools proactively to build, test, and verify your work + - Read files to understand project structure before making changes + - After each file modification, verify the build compiles + - Generate apps that actually run and work as described + + RULES: + - Emit ONE tool call per reply, then stop and wait. Do NOT plan a batch: a tool whose arguments depend on another tool's result (editing a file you just searched for) cannot use a result you have not received yet. + - To locate a file, call search_project ONCE with its name — it searches the whole project. Never walk the tree with repeated list_files calls; you have a limited number of turns and each level wastes one. + - Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and old_string set to just the symbol — not one edit per line. + - To change an existing file, use edit_file (find/replace an exact snippet), not update_file — a whole-file rewrite gets truncated before it reaches disk. + - Before edit_file, read the exact file you are about to edit with read_file, and copy old_string byte-for-byte from that output, including indentation. Never edit a path you have not confirmed exists. + - old_string must be the text currently in the file and new_string what it should become. If they are identical the edit is rejected. + - Never fabricate tool output. Emit a tool call, then wait for the real result before continuing. + - Never write "User:", "Assistant:", a block, or a ```tool_response fence — the system supplies real results. Any tool output you write yourself is a hallucination and will be ignored. + - Do NOT use your provider's native function-calling channel. Tool calls travel in your reply text, in exactly the format below; a structured tool call is not read by this system. + - Paths are relative to the project root and must be complete. If you don't know a file's exact path, find it with search_project or list_files first, then act on the real path — don't guess. + - For plain chat (e.g. "Hi"), just reply briefly with no tool call. When the task is done, either give a short summary with no tool call, or end with a single respond call carrying that summary in its "message" — never an empty respond. + """.trimIndent() + + val workflow = """ + WORKFLOW: + 1. Understand the user's request + 2. List files to understand the project structure + 3. Create/modify files with complete implementations + 4. Add dependencies if needed + 5. Sync gradle and verify compilation + 6. Run the app to confirm it works + 7. Report success and what was built + """.trimIndent() + + val syntax = request.toolCallSyntax ?: return head + "\n\n" + workflow + + val callFormat = """ + TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it: + $syntax + Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing. + The tool only runs when you emit the tool call line itself. + + FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path + only when it is the file you actually mean): + Report the finished task (the summary goes in "message"): + {"tool":"respond","args":{"message":"Renamed count to itemCount."}} + Open a file once you know its path: + {"tool":"open_file","args":{"file_path":"$examplePath"}} + Find a file by name: + {"tool":"search_project","args":{"query":"$exampleStem"}} + List the project's top-level files (an empty directory means the project root): + {"tool":"list_files","args":{"directory":""}} + Change part of a file (line breaks inside a value MUST be written as \n): + {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"count = 0","new_string":"count = 1"}} + """.trimIndent() + + return head + "\n\n" + callFormat + "\n\n" + workflow + } +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/ApiKeyCache.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/ApiKeyCache.kt new file mode 100644 index 00000000..f25bf0f4 --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/ApiKeyCache.kt @@ -0,0 +1,99 @@ +package com.itsaky.androidide.plugins.aiagentopenai.security + +import android.content.SharedPreferences +import android.os.Looper +import com.itsaky.androidide.plugins.PluginLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * The decrypted API key, cached against the value on disk. + * + * Decrypting costs a Keystore IPC round trip and the backend's `isAvailable()` runs on every + * generate, so the cost is paid once and paid again only when the stored key actually changes. + * The main-thread rule lives here too, so the backend never has to know one exists. + * + * @param prefs the store holding the encrypted value; re-read on every call + * @param prefKey the preference name to read + * @param logger this plugin's IDE-surfaced log + * @param scope the owner's scope, used to refresh off a main-thread call + */ +internal class ApiKeyCache( + private val prefs: () -> SharedPreferences?, + private val prefKey: String, + private val logger: PluginLogger, + private val scope: CoroutineScope, +) { + + /** Last decryption, as (value on disk -> plaintext). */ + @Volatile + private var cached: Pair? = null + + /** + * The saved key, or null when none is stored. + * + * Decryption is Keystore IPC + AES/GCM and must not run on the main thread, so a main-thread + * call answers from the cache and kicks off a background refresh rather than blocking; [warm] + * fills the cache first so that never reports "no key". + */ + fun read(): String? { + val stored = prefs()?.getString(prefKey, null) + if (stored.isNullOrBlank()) { + cached = null + return null + } + cached?.let { (raw, plain) -> if (raw == stored) return plain } + if (Looper.myLooper() == Looper.getMainLooper()) { + logger.warn("ApiKeyCache: API key read on the main thread; refreshing off-thread") + // The owner's close() cancels the scope, so without this guard launch is a no-op. + if (scope.isActive) { + scope.launch { refresh() } + } else { + logger.warn("ApiKeyCache: backend already closed; not refreshing the key cache") + } + return null + } + return refresh() + } + + /** + * Fill the cache off-thread, so a synchronous main-thread [read] never reports "no key" for a + * stored, decryptable key just because it was first asked from the UI. + */ + fun warm() { + if (!scope.isActive) return + scope.launch { + try { + val warmed = refresh() != null + logger.debug("ApiKeyCache: key cache warmed (key present: $warmed)") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn("ApiKeyCache: could not warm key cache: ${e.message}") + } + } + } + + /** + * Drop the decrypted key. Otherwise the plaintext stays reachable on the host process heap for + * as long as the IDE runs, which is exactly what encrypting at rest is meant to prevent. + */ + fun clear() { + cached = null + } + + /** + * Decrypt the stored key — upgrading a legacy plaintext value in passing — and cache the + * result. Off-main-thread only; see [read]. + */ + private fun refresh(): String? { + val prefs = prefs() + val plain = SecureApiKeyStore.readAndMigrate(prefs, prefKey) + ?.trim()?.takeIf { it.isNotBlank() } + val raw = prefs?.getString(prefKey, null) + cached = raw?.let { it to plain } + return plain + } +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt new file mode 100644 index 00000000..5f383bd4 --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt @@ -0,0 +1,143 @@ +package com.itsaky.androidide.plugins.aiagentopenai.security + +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import com.itsaky.androidide.plugins.aiagentopenai.logging.LOG_PREFIX +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * AES/GCM encryption for this plugin's API key, keyed by a hardware-backed Android Keystore secret. + * Only ciphertext is written to SharedPreferences, so a copied prefs file (root, `adb backup`, + * forensic dump) is useless without this device's Keystore. + * + * The [ALIAS] must stay stable across releases: a key encrypted under one alias cannot be read + * under another, so changing it silently invalidates every stored key. + * + * It is also deliberately **this plugin's own** alias, not the one ai-agent-gemini uses. Every + * plugin runs in the host app's process and UID and therefore shares one Keystore, so a shared + * alias would let [deleteKey] — the recovery path for an invalidated key — destroy the other + * backend's stored key as a side effect. The two plugins never read each other's ciphertext, so + * they have no reason to share. + */ +object SecureApiKeyStore { + private const val TAG = "$LOG_PREFIX.SecureApiKeyStore" + private const val KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "cotg_ai_openai_key_v1" + private const val TRANSFORM = "AES/GCM/NoPadding" + private const val IV_LEN = 12 + private const val TAG_BITS = 128 + + /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */ + const val ENC_PREFIX = "enc:v1:" + + private fun getOrCreateKey(): SecretKey { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + ) + return generator.generateKey() + } + + private fun deleteKey() { + try { + KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS) + } catch (e: Exception) { + Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e) + } + } + + private fun encryptWith(key: SecretKey, plain: String): String { + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + val combined = ByteArray(iv.size + ciphertext.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size) + return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP) + } + + /** + * Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext). + * + * The key is not auth-bound, so a credential change does not invalidate it; an alias an + * OEM Keystore drops anyway is regenerated once before retrying. + * + * @param plain the value to encrypt + * @throws GeneralSecurityException on any other Keystore/cipher failure, so the caller can + * inform the user instead of crashing the IDE on Save + */ + @Throws(GeneralSecurityException::class) + fun encrypt(plain: String): String { + return try { + encryptWith(getOrCreateKey(), plain) + } catch (e: KeyPermanentlyInvalidatedException) { + Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e) + deleteKey() + encryptWith(getOrCreateKey(), plain) + } + } + + /** + * Return the plaintext for a stored value, handling both formats transparently: + * an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as + * legacy plaintext (use [readAndMigrate] to upgrade it in place). Returns + * null if a ciphertext value can't be decrypted — e.g. the Keystore key was + * lost or invalidated — in which case the user must re-enter the key. + */ + fun decrypt(stored: String?): String? { + if (stored == null) return null + if (!stored.startsWith(ENC_PREFIX)) return stored + return try { + val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP) + val iv = combined.copyOfRange(0, IV_LEN) + val ciphertext = combined.copyOfRange(IV_LEN, combined.size) + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv)) + String(cipher.doFinal(ciphertext), Charsets.UTF_8) + } catch (e: Exception) { + Log.w(TAG, "Failed to decrypt stored API key", e) + null + } + } + + /** + * Read [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place. + * + * The value is trimmed on migration, so the stored, displayed and sent forms all agree. + * + * Keystore IPC + AES/GCM, so call this off the main thread. + * + * @return the trimmed plaintext value, or null when nothing is stored or decryption failed. + */ + fun readAndMigrate(prefs: SharedPreferences?, key: String): String? { + val stored = prefs?.getString(key, null) ?: return null + if (stored.startsWith(ENC_PREFIX)) return decrypt(stored) + val plain = stored.trim() + if (plain.isEmpty()) return plain + try { + prefs.edit().putString(key, encrypt(plain)).apply() + Log.i(TAG, "Upgraded legacy plaintext value for '$key' to ciphertext") + } catch (e: Exception) { + Log.w(TAG, "Could not upgrade legacy plaintext value for '$key' to ciphertext", e) + } + return plain + } +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt new file mode 100644 index 00000000..d981eb32 --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt @@ -0,0 +1,213 @@ +package com.itsaky.androidide.plugins.aiagentopenai.settings + +/** + * Outcome of normalizing a base URL the user typed. + * + * A closed hierarchy so a caller cannot treat "rejected" and "accepted, but cleartext" alike — the + * second is a warning the user may proceed through, the first must block the save. + */ +sealed interface BaseUrlResult { + + /** + * The URL is usable. [url] is the normalized form that should be stored. + * + * @param cleartext true when the URL is plain `http`, so the caller can warn once on save + * @param loopback true when the host is this device, where cleartext carries no LAN exposure + */ + data class Accepted( + val url: String, + val cleartext: Boolean, + val loopback: Boolean, + ) : BaseUrlResult + + /** The URL cannot be used. [reason] says which rule it broke. */ + data class Rejected(val reason: Reason) : BaseUrlResult + + /** Why a URL was refused. Carries no text; the wording lives in `strings.xml`. */ + enum class Reason { + /** Nothing was entered. */ + BLANK, + + /** Not a `http`/`https` URL at all, or unparseable. */ + MALFORMED, + + /** Parsed, but carries no host — `http://` or `https:///v1`. */ + NO_HOST, + + /** Plain `http` to a host that is neither loopback nor a private LAN range. */ + CLEARTEXT_PUBLIC, + } +} + +/** + * Whether the configured server needs an API key, as far as the URL can tell. + * + * Three states, not a boolean, because the settings pane has three things to say: demand a key, + * expect one, or tell the user plainly that none is needed. A boolean forced the pane to show the + * same mandatory-looking field for a local Ollama as for OpenAI. + */ +enum class KeyRequirement { + /** OpenAI's own API: no anonymous access, so the backend is unusable without a key. */ + REQUIRED, + + /** Another server on the internet — OpenRouter, Groq. Usually needs a key; only it knows. */ + EXPECTED, + + /** Loopback or a private address: Ollama, LM Studio and llama-server want no credential. */ + NOT_NEEDED, +} + +/** + * Normalizes and vets the OpenAI-compatible base URL. + * + * Pure and free of Android types, so every rule below is unit-testable without a device. The + * policy: `https` anywhere, `http` only to loopback or a private range (ADFA-3017 §4.6). + */ +internal object BaseUrlPolicy { + + /** Default server, so an untouched install is the plain ChatGPT case ADFA-3017 asked for. */ + const val DEFAULT_BASE_URL = "https://api.openai.com/v1" + + /** Host that means "OpenAI itself", which is the only case where a key is mandatory. */ + private const val OPENAI_HOST = "api.openai.com" + + /** + * Path suffixes a user pastes from OpenAI's docs instead of the base URL. Stripped so + * `.../v1/chat/completions` does not become `.../v1/chat/completions/chat/completions`. + */ + private val PASTED_ENDPOINT_SUFFIXES = listOf( + "/chat/completions", + "/completions", + "/models", + ) + + /** Matches `scheme://host[:port][/path]`, the only shape this backend can call. */ + private val URL_SHAPE = Regex("""^(https?)://([^/?#\s]*)([^?#\s]*)$""", RegexOption.IGNORE_CASE) + + /** Matches an IPv4 address, so its octets can be tested against the private ranges. */ + private val IPV4 = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""") + + /** + * Normalizes [input] and applies the cleartext rule. + * + * Trims, drops a trailing slash and a pasted endpoint path, then decides whether plain `http` + * is acceptable for that host. + * + * @param input the URL as typed + * @return [BaseUrlResult.Accepted] carrying the form to store, or the rule it broke + */ + fun normalize(input: String?): BaseUrlResult { + val trimmed = input?.trim().orEmpty() + if (trimmed.isEmpty()) return BaseUrlResult.Rejected(BaseUrlResult.Reason.BLANK) + + val match = URL_SHAPE.matchEntire(trimmed) + ?: return BaseUrlResult.Rejected(BaseUrlResult.Reason.MALFORMED) + + val scheme = match.groupValues[1].lowercase() + val authority = match.groupValues[2] + if (authority.isBlank()) return BaseUrlResult.Rejected(BaseUrlResult.Reason.NO_HOST) + + val host = hostOf(authority) + if (host.isBlank()) return BaseUrlResult.Rejected(BaseUrlResult.Reason.NO_HOST) + + val path = match.groupValues[3].trimEnd('/').let(::stripPastedEndpoint) + val cleartext = scheme == "http" + val loopback = isLoopback(host) + if (cleartext && !loopback && !isPrivateRange(host)) { + return BaseUrlResult.Rejected(BaseUrlResult.Reason.CLEARTEXT_PUBLIC) + } + + return BaseUrlResult.Accepted( + url = "$scheme://${authority.lowercase()}$path", + cleartext = cleartext, + loopback = loopback, + ) + } + + /** + * True when [url] points at OpenAI's own API, which has no anonymous access. + * + * This is the whole of the "when is a key mandatory" rule: everywhere else — Ollama, LM Studio, + * llama-server — a key is optional, and demanding one there is the ADFA-3452 regression. + */ + fun requiresApiKey(url: String?): Boolean = + keyRequirement(url) == KeyRequirement.REQUIRED + + /** + * How the settings pane should present the key field for [url]. + * + * Derived from the URL alone, so it updates the moment the user picks a preset — no request is + * made. An unusable URL yields [KeyRequirement.REQUIRED], failing closed. + */ + fun keyRequirement(url: String?): KeyRequirement { + val accepted = normalize(url) as? BaseUrlResult.Accepted + ?: return KeyRequirement.REQUIRED + val host = hostOf(accepted.url.substringAfter("://")) + return when { + host.equals(OPENAI_HOST, ignoreCase = true) -> KeyRequirement.REQUIRED + // Loopback and LAN servers are the ones that run unauthenticated by default. + accepted.loopback || isPrivateRange(host) -> KeyRequirement.NOT_NEEDED + else -> KeyRequirement.EXPECTED + } + } + + /** + * Host part of an `host[:port]` authority, with any IPv6 brackets unwrapped. + * + * Drops a trailing path too, so callers may hand it either a bare authority or the + * `host/path` remainder of a full URL. + */ + private fun hostOf(authority: String): String { + val withoutPath = authority.substringBefore('/') + val withoutUserInfo = withoutPath.substringAfterLast('@') + if (withoutUserInfo.startsWith("[")) { + return withoutUserInfo.substringBefore(']').removePrefix("[") + } + return withoutUserInfo.substringBefore(':') + } + + /** Drops an endpoint path the user pasted instead of the base URL. */ + private fun stripPastedEndpoint(path: String): String { + for (suffix in PASTED_ENDPOINT_SUFFIXES) { + if (path.endsWith(suffix, ignoreCase = true)) { + return path.dropLast(suffix.length) + } + } + return path + } + + /** True for this device's own addresses, where cleartext never leaves the machine. */ + private fun isLoopback(host: String): Boolean = + host.equals("localhost", ignoreCase = true) || + host == "::1" || + host.startsWith("127.") + + /** + * True for the RFC 1918 / RFC 4193 ranges plus link-local, i.e. a LAN box. + * + * Cleartext here still crosses the local network, which is why the caller warns; it is allowed + * because that is exactly the "my PC runs Ollama" case ADFA-3452 was filed for. + */ + private fun isPrivateRange(host: String): Boolean { + // Android's emulator maps the developer machine to these, so they behave as LAN hosts. + if (host == "10.0.2.2" || host == "10.0.3.2") return true + // Unique-local and link-local IPv6. + if (host.startsWith("fd", ignoreCase = true) || host.startsWith("fe80:", ignoreCase = true)) { + return true + } + // A bare hostname (no dots) is a LAN name such as `raspberrypi` or a Termux-local alias. + if (!host.contains('.') && !host.contains(':')) return true + + val octets = IPV4.matchEntire(host)?.groupValues?.drop(1)?.map { it.toIntOrNull() ?: -1 } + ?: return false + if (octets.any { it !in 0..255 }) return false + val (first, second) = octets + return when { + first == 10 -> true + first == 192 && second == 168 -> true + first == 172 && second in 16..31 -> true + first == 169 && second == 254 -> true + else -> false + } + } +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/CatalogResult.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/CatalogResult.kt new file mode 100644 index 00000000..d9a5a4e0 --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/CatalogResult.kt @@ -0,0 +1,22 @@ +package com.itsaky.androidide.plugins.aiagentopenai.settings + +/** + * Outcome of one model-catalog lookup against the OpenAI-compatible backend. + * + * A closed hierarchy, so callers cannot treat "the backend isn't installed" and "the server refused + * the key" alike. + */ +sealed interface CatalogResult { + + /** The server answered. [models] may be empty, which many compatible servers do. */ + data class Success(val models: List) : CatalogResult + + /** No backend was resolvable — this plugin is not active, or was disposed. */ + data object NoBackend : CatalogResult + + /** + * The lookup failed. [cause] is the *unwrapped* failure — the backend's [java.io.IOException] + * for an HTTP error, or a [java.util.concurrent.TimeoutException]. + */ + data class Failed(val cause: Throwable) : CatalogResult +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ConnectionVerification.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ConnectionVerification.kt new file mode 100644 index 00000000..277a0d41 --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ConnectionVerification.kt @@ -0,0 +1,125 @@ +package com.itsaky.androidide.plugins.aiagentopenai.settings + +import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiHttpException +import java.io.IOException + +/** + * What a live check against the configured server established. + * + * [Rejected] is a confirmed refusal and blocks a key save; everything else establishes less than + * that, and collapsing them together would either save bad keys or block a perfectly good local + * server that simply does not implement `/v1/models`. + */ +sealed interface ConnectionVerification { + + /** The server answered and offers [modelCount] plausible chat models. */ + data class Verified(val modelCount: Int) : ConnectionVerification + + /** + * The server answered but has no models to offer. + * + * Its own state because it is actionable and common: an Ollama install with nothing pulled + * yet. The URL and credential are fine; there is just nothing to run. + */ + data object NoModels : ConnectionVerification + + /** + * The server accepted the credential but is rate-limiting (HTTP 429). + * + * Treated as confirmed on purpose — calling this "rejected" would send users off to mint a + * second key that behaves identically. + */ + data object RateLimited : ConnectionVerification + + /** The server refused the credential (HTTP 401/403). The only state that blocks a save. */ + data object Rejected : ConnectionVerification + + /** + * The server answered 404, so it is running but the path is wrong. + * + * Almost always a base URL missing its `/v1` suffix, which is the most common setup mistake + * for every compatible server — hence a distinct verdict with distinct advice. + */ + data object EndpointNotFound : ConnectionVerification + + /** Nothing answered — no network, nothing listening on that port, DNS failure, or a 5xx. */ + data object Unreachable : ConnectionVerification + + /** Nothing could be checked: the backend was not resolvable, or the failure was unrecognised. */ + data object Unknown : ConnectionVerification + + /** + * True when the server confirmed the credential works. This is the save rule in one place: a + * key is written only when this is true, or when the user overrides an *inconclusive* check. + */ + val isConfirmedValid: Boolean + get() = this is Verified || this is RateLimited +} + +/** + * Interpret a catalog lookup as a verdict on the server and credential that produced it. + * + * Pure: no Android state and no logging of its own — the gateway already reported the failure — so + * every row of the mapping is unit-testable without a device or a live server. + */ +internal fun CatalogResult.toConnectionVerification(): ConnectionVerification = when (this) { + is CatalogResult.Success -> + if (models.isEmpty()) { + ConnectionVerification.NoModels + } else { + ConnectionVerification.Verified(models.size) + } + + CatalogResult.NoBackend -> ConnectionVerification.Unknown + + is CatalogResult.Failed -> classifyFailure(cause) +} + +/** + * Map a lookup failure onto a verdict using the status the transport reports as a field. + * + * Note 404 does **not** reject: a compatible server that lacks `/v1/models` answers 404 with a + * perfectly good key, and rejecting there would make it unconfigurable. + */ +private fun classifyFailure(cause: Throwable): ConnectionVerification = + when (val status = failureStatusOf(cause)) { + null -> if (cause is IOException) { + ConnectionVerification.Unreachable + } else { + ConnectionVerification.Unknown + } + // Ordered before the 4xx range: a throttled key is valid, and must not read as refused. + 429 -> ConnectionVerification.RateLimited + 404 -> ConnectionVerification.EndpointNotFound + 401, 403 -> ConnectionVerification.Rejected + // The server's fault, not the credential's: a 5xx says nothing about the key. + in 500..599 -> ConnectionVerification.Unreachable + // Any other 4xx is the client's fault, but not necessarily the key's. + in 400..499 -> if (status == 400) { + ConnectionVerification.Unknown + } else { + ConnectionVerification.Rejected + } + else -> ConnectionVerification.Unknown + } + +/** Depth cap: a malformed cause chain can be self-referential, and this runs on user input. */ +private const val MAX_CAUSE_DEPTH = 5 + +/** + * First status found walking [cause] and its causes, or null when no HTTP answer was involved. + * + * Reads [OpenAiHttpException.statusCode], never message text: a status matched out of a formatted + * message made a log line's wording a contract, and the server's own error body — which that + * message carries — could forge one. + */ +private fun failureStatusOf(cause: Throwable): Int? { + var current: Throwable? = cause + var depth = 0 + while (current != null && depth < MAX_CAUSE_DEPTH) { + (current as? OpenAiHttpException)?.let { return it.statusCode } + current = current.cause + depth++ + } + return null +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ModelSelection.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ModelSelection.kt new file mode 100644 index 00000000..f5f9a611 --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ModelSelection.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.plugins.aiagentopenai.settings + +/** + * Decides whether the saved model still applies once the server or its catalog changed. + * + * Pure, so the rule that keeps a `gpt-5` selection from following the user to an Ollama server — + * where it would 404 on the first message, long after the settings pane was closed — is testable. + */ +internal object ModelSelection { + + /** + * The model to switch to, or null to keep the one already saved. + * + * @param current the model saved right now + * @param models the catalog to choose from; empty means nothing was discovered + * @param isLive true when [models] came from a live fetch, so an absent model is real + * @param savedForThisServer true when [current] was chosen for the server now configured + * @param preferred the model to favour when [current] has to go, if the catalog offers it + * @return the replacement model, or null when [current] still applies + */ + fun adopt( + current: String, + models: List, + isLive: Boolean, + savedForThisServer: Boolean, + preferred: String, + ): String? { + if (models.isEmpty() || models.contains(current)) return null + // A remembered list can be months stale, so it only overrides a model from another server. + if (!isLive && savedForThisServer) return null + return models.firstOrNull { it == preferred } ?: models.first() + } +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiCatalogGateway.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiCatalogGateway.kt new file mode 100644 index 00000000..860d248c --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiCatalogGateway.kt @@ -0,0 +1,119 @@ +package com.itsaky.androidide.plugins.aiagentopenai.settings + +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.aiagentopenai.backend.OpenAiBackend +import com.itsaky.androidide.plugins.aiagentopenai.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.aiagentopenai.plugin.OpenAiPlugin +import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * The one place this plugin's settings ask for a model catalog. + * + * An abstraction the ViewModel can fake in tests, so the blocking wait on the backend's future + * lives behind a single seam that fails in one recognisable way. + */ +interface OpenAiCatalogGateway { + + /** + * Models available with the settings currently saved on disk. Used to populate the model + * picker, where "which server" is never in question. + */ + fun listModelsForSavedSettings(): CatalogResult + + /** + * Models available at [baseUrl] with [apiKey], neither of which need be — and during setup is + * not — the saved pair. This is what makes testing a server before persisting it possible. + * + * @param apiKey the candidate key, or blank for a server that needs none + * @param baseUrl the candidate server, already normalized + */ + fun listModels(apiKey: String, baseUrl: String): CatalogResult +} + +/** + * [OpenAiCatalogGateway] over this plugin's own [OpenAiBackend]. + * + * A plain call: the backend and the settings that configure it ship in the same `.cgp`, so the + * types are the same types — no reflection across a classloader boundary. + * + * @param backendProvider resolves the backend; injectable so tests need no plugin lifecycle + */ +class BackendOpenAiCatalogGateway( + private val backendProvider: () -> OpenAiBackend? = OpenAiPlugin::getBackend +) : OpenAiCatalogGateway { + + companion object { + private const val TAG = "$LOG_PREFIX.OpenAiCatalogGateway" + + /** + * Failsafe cap, well above the backend's own budget (15 s connect + 15 s read) so a + * slow-but-live fetch is never truncated. Bounds a future that may never complete, such as + * one from an already-cancelled backend scope; not the expected wait. + */ + private const val LIST_MODELS_TIMEOUT_SECONDS = 60L + } + + /** + * This plugin's IDE-surfaced log, so a failed catalog lookup shows up in the IDE's own log view + * rather than only in logcat. Null before `initialize()` and in JVM tests. + */ + private val logger: PluginLogger? + get() = OpenAiPlugin.getContext()?.logger + + override fun listModelsForSavedSettings(): CatalogResult = + await { it.listModels() } + + override fun listModels(apiKey: String, baseUrl: String): CatalogResult = + await { it.listModels(apiKey, baseUrl) } + + /** + * Runs [request] against the backend and awaits its future. + * + * Blocks on [CompletableFuture.get], so call it from an IO dispatcher — never the main thread. + * + * @param request the catalog call to make; picks which server and credential are used + */ + private fun await( + request: (OpenAiBackend) -> CompletableFuture> + ): CatalogResult { + val backend = try { + backendProvider() + } catch (e: Exception) { + logger?.error("$TAG: could not resolve the OpenAI backend", e) + return CatalogResult.Failed(e) + } ?: return CatalogResult.NoBackend + + val future = try { + request(backend) + } catch (e: Exception) { + logger?.error("$TAG: listModels threw", e) + return CatalogResult.Failed(e) + } + + return try { + CatalogResult.Success(future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS).orEmpty()) + } catch (e: ExecutionException) { + // The API failure the backend reported; its message carries the HTTP status. + CatalogResult.Failed(e.cause ?: e) + } catch (e: CancellationException) { + logger?.warn("$TAG: listModels was cancelled by the backend", e) + CatalogResult.Failed(e) + } catch (e: TimeoutException) { + future.cancel(true) + logger?.error( + "$TAG: listModels did not complete within ${LIST_MODELS_TIMEOUT_SECONDS}s", + e + ) + CatalogResult.Failed(e) + } catch (e: InterruptedException) { + // Restore the flag so the cancelled coroutine's thread still sees it. + Thread.currentThread().interrupt() + future.cancel(true) + CatalogResult.Failed(e) + } + } +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiKeyOnboarding.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiKeyOnboarding.kt new file mode 100644 index 00000000..80f3225f --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiKeyOnboarding.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.plugins.aiagentopenai.settings + +/** + * Where an OpenAI API key comes from. + * + * Open [API_KEYS_URL] in a real browser, sign in there, copy the key, paste it into the key field. + * This plugin never sees a password, and never reads the clipboard. + */ +object OpenAiKeyOnboarding { + + /** + * OpenAI's API keys page. + * + * Note there is no free tier: a key needs a prepaid balance and a ChatGPT subscription does not + * include API access. The free paths are a local server — which the URL field reaches — or one + * of the other backend plugins; the settings pane and the guide both say so. + */ + const val API_KEYS_URL = "https://platform.openai.com/api-keys" +} diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt new file mode 100644 index 00000000..8f7d959c --- /dev/null +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt @@ -0,0 +1,802 @@ +package com.itsaky.androidide.plugins.aiagentopenai.settings + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.method.HideReturnsTransformationMethod +import android.text.method.PasswordTransformationMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.Button +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiagentopenai.R +import com.itsaky.androidide.plugins.aiagentopenai.plugin.OpenAiPlugin +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.services.IdeTooltipService +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * This backend's settings pane, mounted by whichever screen offers a backend selector. + * + * Named to the host through `OpenAiBackend.getSettingsFragmentClassName()`, loaded with this + * plugin's own classloader and inflated against this plugin's own resources — so the consumer needs + * to know nothing about API keys, server URLs or model catalogs. + */ +class OpenAiSettingsFragment : Fragment() { + + private lateinit var viewModel: OpenAiSettingsViewModel + private var tooltipService: IdeTooltipService? = null + + /** + * Re-reads the server URL and re-dresses the key section for it. Set once the key section is + * built, so the server section can call it whenever the URL changes. + */ + private var onServerChanged: ((String) -> Unit)? = null + + /** + * Set while this pane is on screen, so [onResume] can nudge the user towards **Save** after + * they come back from the key page. Cleared when the view is destroyed — it captures views, so + * holding it any longer would leak them. + */ + private var onPaneResume: (() -> Unit)? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + try { + tooltipService = PluginFragmentHelper.getServiceRegistry(OpenAiPlugin.PLUGIN_ID) + ?.get(IdeTooltipService::class.java) + } catch (e: Exception) { + // Tooltip help is optional; long-press simply shows nothing when it's unavailable. + OpenAiPlugin.getContext()?.logger + ?.warn("OpenAiSettingsFragment: tooltip service unavailable", e) + } + } + + /** + * Route inflation through the host so this pane resolves against *this* plugin's resources and + * a Context whose Configuration tracks the IDE's day/night setting. The inflater inherited from + * the hosting screen belongs to that plugin and cannot see this one's layouts. + */ + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(OpenAiPlugin.PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_openai_settings, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider( + this, + OpenAiSettingsViewModelFactory { OpenAiPlugin.getContext() } + )[OpenAiSettingsViewModel::class.java] + + // The key section publishes onServerChanged, so it is built before the server section that + // fires it, and before the first call below that dresses the pane for the saved server. + setupApiKeyUi(view) + setupServerUi(view) + setupModelUi(view) + setupConnectionTest(view) + onServerChanged?.invoke(viewModel.getBaseUrl()) + } + + override fun onResume() { + super.onResume() + onPaneResume?.invoke() + } + + override fun onDestroyView() { + // Drops the captured pane views along with the callbacks. + onPaneResume = null + onServerChanged = null + setSecureWindow(false) + super.onDestroyView() + } + + /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide button). */ + private fun wireTooltip(view: View, tag: String) { + view.setOnLongClickListener { anchor -> + val service = tooltipService ?: return@setOnLongClickListener false + service.showTooltip(anchor, OpenAiPlugin.TOOLTIP_CATEGORY, tag) + true + } + } + + /** + * Show [message] on [target] with an optional leading status icon. + * + * @param icon leading status drawable, or 0 for the states that don't warrant one + */ + private fun showStatus(target: TextView, message: String, @DrawableRes icon: Int = 0) { + target.text = message + // Relative (not left/right) so the icon follows the layout direction in RTL locales. + target.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, 0, 0, 0) + target.visibility = View.VISIBLE + } + + /** Drop a status line that no longer describes what is on screen. */ + private fun hideStatus(target: TextView) { + target.visibility = View.GONE + target.text = "" + target.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + } + + // --- Server ------------------------------------------------------------------------------- + + @SuppressLint("ClickableViewAccessibility") + private fun setupServerUi(view: View) { + val presetSpinner = view.findViewById(R.id.openai_preset_spinner) + val urlInput = view.findViewById(R.id.openai_base_url_input) + val saveButton = view.findViewById