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 URL
What it is
+
https://api.openai.com/v1
Default. OpenAI itself.
+
http://localhost:11434/v1
Ollama running on the device.
+
http://192.168.1.50:11434/v1
Ollama on your own 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.
+
+
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
+
+
Chat completions over POST {baseUrl}/chat/completions,
+ with text completion, real multi-turn history as a
+ messages[] array, and server-sent-events streaming.
+
Server-aware key field — the API key section adapts to the chosen
+ server as you pick it: mandatory for OpenAI, optional for another cloud
+ provider, and hidden entirely for a local or LAN address that needs no
+ credential.
+
Free-text or discovered models — one field that is also a list:
+ type any model name, or pick one the server reported. Non-chat models
+ (embeddings, audio, images) are filtered out.
+
Test Connection & List Models — one request that both checks the
+ URL and key without saving either and fills the model list. Distinguishes a
+ refused key, a wrong path, an unreachable server and a server with no models
+ loaded.
+
Reasoning-model handling — gpt-5.x and the
+ o series take max_completion_tokens and may reject
+ temperature; the right parameters are chosen from the model
+ name, and a refused parameter is retried once without it.
+
Encrypted at rest — the API key is stored as AES/GCM ciphertext
+ under a hardware-backed Android Keystore secret.
+
Translated, safe error messages — a failure becomes one
+ user-facing sentence; the raw HTTP error body stays in the log and never
+ reaches the chat transcript.
+
+
+
Technical architecture
+
+
Component
Role
+
OpenAiPlugin
Plugin 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.
+
OpenAiBackend
The transport. Calls
+ chat/completions over HttpURLConnection, parses the
+ streaming response, and fetches the model catalog.
+
BaseUrlPolicy
Normalizes the server URL — trims a
+ pasted /chat/completions path, lowercases the host — and
+ enforces the cleartext rule.
+
RequestTuning
Decides which optional parameters a
+ request carries, and which one to stop sending after a server refuses
+ it.
+
OpenAiErrorFormatter
Classifies a failure
+ (unknown model, rate limit, spent balance, refused key, outage, server not
+ running) so it can be reported as one translated sentence.
+
SecureApiKeyStore
AES/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
+
+
Install AI Core and AI Agent OpenAI via the Plugin Manager,
+ then restart the IDE.
+
Open Preferences → Configuration → Agent and select
+ OpenAI as the backend.
+
Set the Server: keep the OpenAI default, or pick a preset and edit
+ the host to reach your own machine.
+
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.
+
Tap Test Connection & List Models, then set the Model by
+ picking from that list or typing a name.
+
+
+ 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
+
+
Frontier models on a device that could not run them locally.
+
Or your own hardware — the same plugin reaches a large model on
+ your PC over Wi-Fi, at no cost and without your code leaving the
+ network.
+
Small footprint — no bundled model or native library.
+
Credential hygiene — encrypted at rest, header-only in transit,
+ dropped from memory when the plugin unloads.
+
Legible failures — a wrong path, a stopped server and a refused key
+ each say so specifically instead of producing one generic error.
+
Coexists with the other backends — install several and switch in
+ Agent settings.
+
+
+
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 URL
What it is
+
https://api.openai.com/v1
OpenAI itself. The default.
+
http://localhost:11434/v1
Ollama running on this device.
+
http://192.168.1.50:11434/v1
Ollama on your own 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.
+
+
+
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 00000000..029e044e
Binary files /dev/null and b/ai-agent-openai/src/main/assets/icon_day.png differ
diff --git a/ai-agent-openai/src/main/assets/icon_night.png b/ai-agent-openai/src/main/assets/icon_night.png
new file mode 100644
index 00000000..8d7154f9
Binary files /dev/null and b/ai-agent-openai/src/main/assets/icon_night.png differ
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ChatModelFilter.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ChatModelFilter.kt
new file mode 100644
index 00000000..79e384e8
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ChatModelFilter.kt
@@ -0,0 +1,49 @@
+package com.itsaky.androidide.plugins.aiagentopenai.backend
+
+/**
+ * Keeps the chat-capable ids out of a `GET /v1/models` listing.
+ *
+ * Unlike Gemini's catalog, OpenAI's returns `{id, created, owned_by}` with **no capability flag**,
+ * so a raw listing mixes in embedding, audio and image models. This is therefore a heuristic, and
+ * deliberately a denylist: an unknown id is kept, because a wrongly hidden model cannot be selected
+ * at all while a wrongly offered one merely fails once with a clear server error.
+ */
+internal object ChatModelFilter {
+
+ /**
+ * Substrings that mark a non-chat model.
+ *
+ * Matched on the whole id, so vendor-prefixed OpenRouter ids are covered too.
+ */
+ private val NON_CHAT_MARKERS = listOf(
+ "embed", "embedding",
+ "whisper", "tts", "audio", "transcribe", "realtime",
+ "dall-e", "dalle", "image", "stable-diffusion", "sdxl", "flux",
+ "moderation", "guard",
+ "rerank",
+ "clip", "vit",
+ )
+
+ /**
+ * Filters and orders a raw model listing.
+ *
+ * @param ids model ids exactly as the server returned them
+ * @return the plausible chat models, de-duplicated and sorted for a stable picker
+ */
+ fun chatModels(ids: List): List = ids
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .filter(::isPlausibleChatModel)
+ .distinct()
+ .sorted()
+
+ /**
+ * True when [id] could be a chat model.
+ *
+ * @param id one model id from the listing
+ */
+ fun isPlausibleChatModel(id: String): Boolean {
+ val normalized = id.lowercase()
+ return NON_CHAT_MARKERS.none { normalized.contains(it) }
+ }
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
new file mode 100644
index 00000000..670ee2fa
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
@@ -0,0 +1,579 @@
+package com.itsaky.androidide.plugins.aiagentopenai.backend
+
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiagentopenai.R
+import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiErrorFormatter
+import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiFailure
+import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiFailureMessages
+import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiHttpException
+import com.itsaky.androidide.plugins.aiagentopenai.preferences.OpenAiPreferences
+import com.itsaky.androidide.plugins.aiagentopenai.prompt.OpenAiSystemPrompt
+import com.itsaky.androidide.plugins.aiagentopenai.security.ApiKeyCache
+import com.itsaky.androidide.plugins.aiagentopenai.settings.BaseUrlPolicy
+import com.itsaky.androidide.plugins.aiagentopenai.settings.BaseUrlResult
+import com.itsaky.androidide.plugins.services.LlmInferenceService.*
+import java.io.IOException
+import java.util.concurrent.CompletableFuture
+import kotlin.coroutines.coroutineContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * OpenAI-compatible backend: one transport for every server that speaks `chat/completions`.
+ *
+ * The base URL is a setting, defaulting to OpenAI's own API. Across OpenAI, Ollama, LM Studio,
+ * OpenRouter and llama-server the auth header, request JSON, SSE framing and error shape are
+ * identical — only the host changes — so this is one backend rather than one per provider.
+ *
+ * What this class owns is the *conversation*: which model, which turns, what to do when a server
+ * rejects a parameter or answers nothing. Sockets are [OpenAiHttpClient]'s, the decrypted key is
+ * [ApiKeyCache]'s, and the wording of a failure is [OpenAiFailureMessages]'.
+ */
+class OpenAiBackend(
+ private val context: PluginContext
+) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend {
+
+ private val scope = CoroutineScope(Dispatchers.IO)
+
+ private val http = OpenAiHttpClient()
+
+ private val keyCache =
+ ApiKeyCache(::openAiPrefs, OpenAiPreferences.KEY_API_KEY, context.logger, scope)
+
+ private val failureMessages = OpenAiFailureMessages(context, ::getBaseUrl)
+
+ @Volatile
+ private var currentJob: Job? = null
+
+ companion object {
+ /** Backend id, as persisted by AI Core when the user selects this backend. */
+ const val BACKEND_ID = "openai"
+
+ /** Default model, matching the default base URL. Editable on this backend's settings pane. */
+ const val DEFAULT_MODEL = "gpt-5"
+
+ /** Chat endpoint, appended to the configured base URL. */
+ private const val CHAT_COMPLETIONS_PATH = "/chat/completions"
+
+ /** Model-catalog endpoint. Optional: many compatible servers do not implement it. */
+ private const val MODELS_PATH = "/models"
+ }
+
+ /** This plugin's own settings, written by its settings pane and read here at request time. */
+ private fun openAiPrefs(): SharedPreferences? = try {
+ OpenAiPreferences.of(context)
+ } catch (e: Exception) {
+ context.logger.error("OpenAiBackend: Error getting preferences", e)
+ null
+ }
+
+ /**
+ * The configured server, normalized, falling back to OpenAI's own API.
+ *
+ * Re-normalized on read rather than trusted: a value written by an older build has not been
+ * through the policy.
+ */
+ private fun getBaseUrl(): String {
+ val stored = openAiPrefs()?.getString(OpenAiPreferences.KEY_BASE_URL, null)
+ val accepted = BaseUrlPolicy.normalize(stored) as? BaseUrlResult.Accepted
+ return accepted?.url ?: BaseUrlPolicy.DEFAULT_BASE_URL
+ }
+
+ /** The model to request, or the default when nothing is stored. */
+ private fun getModelName(): String =
+ openAiPrefs()?.getString(OpenAiPreferences.KEY_MODEL, DEFAULT_MODEL)
+ ?.trim()?.takeIf { it.isNotEmpty() }
+ ?: DEFAULT_MODEL
+
+ /**
+ * Decrypt the stored key off-thread now, so a main-thread [isAvailable] can't report "no key"
+ * for a key that is there. Called once, on activation.
+ */
+ fun warmKeyCache() = keyCache.warm()
+
+ override fun getId(): String = BACKEND_ID
+
+ /** Falls back to a literal: an empty name would be an unlabelled row in the selector. */
+ override fun getName(): String = configLabel(R.string.openai_backend_name, fallback = "OpenAI")
+
+ /**
+ * Resolves a label against this plugin's own resources, degrading rather than throwing —
+ * [getName] is called across the plugin boundary.
+ *
+ * @param fallback returned when the lookup fails
+ */
+ private fun configLabel(resId: Int, fallback: String = ""): String = try {
+ context.androidContext.getString(resId)
+ } catch (e: Exception) {
+ context.logger.error("OpenAiBackend: could not resolve label $resId", e)
+ fallback
+ }
+
+ /**
+ * Written for a large cloud model; see [OpenAiSystemPrompt] for why the wording belongs here
+ * rather than with the caller.
+ */
+ override fun getSystemPrompt(request: SystemPromptRequest): String =
+ OpenAiSystemPrompt.build(request)
+
+ /**
+ * Room to plan, matching the high-autonomy prompt this backend asks for — or null for a
+ * reasoning model, several of which reject `temperature` outright.
+ */
+ override fun getDefaultTemperature(): Float? =
+ if (RequestTuning.isReasoningModel(getModelName())) null else 0.7f
+
+ /**
+ * This backend draws its own settings, so the consumer needs no knowledge of API keys, base
+ * URLs or server presets.
+ */
+ override fun getSettingsFragmentClassName(): String =
+ "com.itsaky.androidide.plugins.aiagentopenai.settings.OpenAiSettingsFragment"
+
+ /**
+ * Available when the server can plausibly be called.
+ *
+ * A key is required only for OpenAI's own API. For any other base URL a non-blank URL is
+ * enough: local Ollama and LM Studio need no credential, and demanding one would leave this
+ * backend permanently "not available" for the users who asked for a custom server.
+ */
+ override fun isAvailable(): Boolean {
+ val baseUrl = getBaseUrl()
+ if (!BaseUrlPolicy.requiresApiKey(baseUrl)) {
+ context.logger.debug("OpenAiBackend.isAvailable() - custom server configured: $baseUrl")
+ return true
+ }
+ val apiKey = keyCache.read()
+ context.logger.debug("OpenAiBackend.isAvailable() - API key configured: ${!apiKey.isNullOrBlank()}")
+ return !apiKey.isNullOrBlank()
+ }
+
+ override fun generate(prompt: String, config: LlmConfig): CompletableFuture {
+ val future = CompletableFuture()
+
+ currentJob = scope.launch {
+ try {
+ val startTime = System.currentTimeMillis()
+ context.logger.info("OpenAiBackend: Generating response for prompt (${prompt.length} chars)")
+
+ val messages = OpenAiRequestBuilder.messages(emptyList(), prompt, config.systemPrompt)
+ val text = requestText(messages, config)
+
+ if (text.isBlank()) {
+ future.complete(LlmResponse.failure(failureMessages.of(OpenAiFailure.Failed(null))))
+ } else {
+ val tokenCount = text.split("\\s+".toRegex()).size // Approximate token count
+ context.logger.info("OpenAiBackend: Generated ${text.length} chars, ~$tokenCount tokens")
+ future.complete(LlmResponse.success(text, tokenCount, System.currentTimeMillis() - startTime))
+ }
+ } catch (e: CancellationException) {
+ future.cancel(true)
+ throw e
+ } catch (e: Exception) {
+ context.logger.error("OpenAiBackend: Error generating response", e)
+ future.complete(LlmResponse.failure(formatErrorMessage(e)))
+ }
+ }
+
+ return future
+ }
+
+ override fun generateStreaming(
+ prompt: String,
+ config: LlmConfig,
+ callback: StreamCallback
+ ) {
+ streamMessages(
+ OpenAiRequestBuilder.messages(emptyList(), prompt, config.systemPrompt),
+ config,
+ callback
+ )
+ }
+
+ override fun generateWithHistory(
+ history: List,
+ prompt: String,
+ config: LlmConfig
+ ): CompletableFuture {
+ context.logger.info("OpenAiBackend.generateWithHistory() called with ${history.size} messages")
+
+ val future = CompletableFuture()
+
+ currentJob = scope.launch {
+ try {
+ val startTime = System.currentTimeMillis()
+
+ val messages = OpenAiRequestBuilder.messages(history, prompt, config.systemPrompt)
+ val text = requestText(messages, config)
+
+ if (text.isBlank()) {
+ future.complete(LlmResponse.failure(failureMessages.of(OpenAiFailure.Failed(null))))
+ } else {
+ val tokenCount = text.split("\\s+".toRegex()).size
+ context.logger.info("OpenAiBackend: Generated ${text.length} chars with history, ~$tokenCount tokens")
+ future.complete(LlmResponse.success(text, tokenCount, System.currentTimeMillis() - startTime))
+ }
+ } catch (e: CancellationException) {
+ future.cancel(true)
+ throw e
+ } catch (e: Exception) {
+ context.logger.error("OpenAiBackend: Error generating with history", e)
+ future.complete(LlmResponse.failure(formatErrorMessage(e)))
+ }
+ }
+
+ return future
+ }
+
+ /**
+ * Streams a reply for a multi-turn conversation, sending [history] as real `messages[]` turns.
+ *
+ * @param history the conversation so far, oldest first
+ * @param prompt the current user turn
+ * @param config sampling settings; its system prompt becomes the leading `system` turn
+ * @param callback receives tokens, completion, and errors
+ */
+ override fun generateStreamingWithHistory(
+ history: List,
+ prompt: String,
+ config: LlmConfig,
+ callback: StreamCallback
+ ) {
+ streamMessages(
+ OpenAiRequestBuilder.messages(history, prompt, config.systemPrompt),
+ config,
+ callback
+ )
+ }
+
+ /**
+ * Streams one `chat/completions` request over the already-built [messages].
+ *
+ * @param messages the request's `messages[]` turns
+ * @param config sampling settings for this request
+ * @param callback receives tokens, completion, and errors
+ */
+ private fun streamMessages(
+ messages: JSONArray,
+ config: LlmConfig,
+ callback: StreamCallback
+ ) {
+ currentJob = scope.launch {
+ try {
+ val startTime = System.currentTimeMillis()
+ context.logger.info("OpenAiBackend: Streaming over ${messages.length()} turns")
+
+ val fullText = StringBuilder()
+ var chunkCount = 0
+ var outcome = StreamOutcome()
+ // The retry exists because reasoning models and third-party servers disagree about
+ // max_tokens/temperature; see RequestTuning.
+ withParameterRetry(config) { tuning ->
+ fullText.clear()
+ chunkCount = 0
+ val body = OpenAiRequestBuilder.body(
+ messages, getModelName(), stream = true, config = config, tuning = tuning
+ )
+ outcome = streamOnce(body) { chunk ->
+ chunkCount++
+ fullText.append(chunk)
+ callback.onToken(chunk)
+ }
+ }
+
+ val finalText = fullText.toString()
+ if (finalText.isBlank()) {
+ // The request succeeded and the stream ended, so this is not a failed request;
+ // say which of the empty-reply cases it was instead of a generic error.
+ context.logger.warn(
+ "OpenAiBackend: stream produced no reply text " +
+ "(skipped=${outcome.skippedChunks}, " +
+ "reasoningChars=${outcome.reasoningChars}, " +
+ "finishReason=${outcome.finishReason})"
+ )
+ callback.onError(failureMessages.of(emptyReplyFailure(outcome)))
+ } else {
+ val tokenCount = finalText.split("\\s+".toRegex()).size
+ context.logger.info("OpenAiBackend: Streamed ${finalText.length} chars in $chunkCount chunks, ~$tokenCount tokens")
+ callback.onComplete(LlmResponse.success(finalText, tokenCount, System.currentTimeMillis() - startTime))
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ ensureActive()
+ context.logger.error("OpenAiBackend: Error in streaming", e)
+ callback.onError(formatErrorMessage(e))
+ }
+ }
+ }
+
+ /**
+ * What one streaming attempt observed beyond the reply text itself.
+ *
+ * Collected so a stream that ends with no content can say *why* — the difference between a
+ * reasoning model that never answered, a server that reported an error inside a 200, and a
+ * shape this parser does not understand.
+ *
+ * @param skippedChunks payloads the parser could not use
+ * @param reasoningChars thinking text seen, which is never part of the reply
+ * @param finishReason the last `finish_reason` the server sent, if any
+ */
+ private data class StreamOutcome(
+ var skippedChunks: Int = 0,
+ var reasoningChars: Int = 0,
+ var finishReason: String? = null,
+ )
+
+ /**
+ * POST [body] and feed each streamed chunk to [onChunk].
+ *
+ * Tokens already delivered before a mid-stream failure stay delivered; the caller resets its
+ * buffer before a retry, which only ever happens on a 400 raised before any token arrived.
+ *
+ * @return what else the stream carried, for diagnosing an empty reply
+ */
+ private suspend fun streamOnce(
+ body: JSONObject,
+ onChunk: (String) -> Unit
+ ): StreamOutcome {
+ val outcome = StreamOutcome()
+ // Hoisted: the reader below is an ordinary lambda, with no suspend context of its own.
+ val requestContext = coroutineContext
+ var cancelHandle: DisposableHandle? = null
+ try {
+ http.post(
+ url = getBaseUrl() + CHAT_COMPLETIONS_PATH,
+ apiKey = readApiKeyOrBlank(),
+ body = body,
+ sse = true,
+ onConnected = { conn ->
+ cancelHandle = requestContext[Job]?.invokeOnCompletion { cause ->
+ if (cause != null) conn.disconnect()
+ }
+ },
+ ) { reader ->
+ for (line in reader.lineSequence()) {
+ requestContext.ensureActive()
+ when (val event = SseChunk.parse(line)) {
+ is SseChunk.Event.Token -> onChunk(event.text)
+
+ // Not shown, but proof the model was working; see StreamOutcome.
+ is SseChunk.Event.Reasoning ->
+ outcome.reasoningChars += event.text.length
+
+ // A 200 whose body carries the real error: raised so it reaches the same
+ // classifier as an HTTP-level failure instead of ending the stream empty.
+ is SseChunk.Event.Failure ->
+ throw IOException("OpenAI stream error: ${event.message}")
+
+ is SseChunk.Event.Finish -> outcome.finishReason = event.reason
+
+ SseChunk.Event.Done -> break
+ SseChunk.Event.Ignored -> Unit
+
+ // One bad chunk must not abort a stream that is otherwise producing text.
+ is SseChunk.Event.Malformed -> {
+ outcome.skippedChunks++
+ context.logger.warn(
+ "OpenAiBackend: skipping SSE chunk: ${event.detail}"
+ )
+ }
+ }
+ }
+ }
+ } finally {
+ cancelHandle?.dispose()
+ }
+ return outcome
+ }
+
+ /**
+ * Which empty-reply case [outcome] describes.
+ *
+ * Ordered by how actionable the advice is: reasoning that ate the budget and a truncating
+ * token cap both have a fix the user can apply, while an unrecognised shape only has a log.
+ */
+ private fun emptyReplyFailure(outcome: StreamOutcome): OpenAiFailure = when {
+ outcome.reasoningChars > 0 -> OpenAiFailure.ReasoningOnly
+ outcome.finishReason == "length" -> OpenAiFailure.TruncatedBeforeReply
+ else -> OpenAiFailure.EmptyReply(outcome.skippedChunks)
+ }
+
+ /**
+ * Run [attempt] and, if the server rejected one optional parameter, run it once more without it.
+ *
+ * Compatible servers vary too much to hardcode which parameters each accepts, so the matrix is
+ * discovered from the one 400 that names the offender.
+ *
+ * @param config supplies the model, which decides the starting tuning
+ * @param attempt the request to make, given the tuning to use
+ */
+ private suspend fun withParameterRetry(
+ config: LlmConfig,
+ attempt: suspend (RequestTuning) -> Unit
+ ) {
+ val model = getModelName()
+ val tuning = RequestTuning.forModel(model, BaseUrlPolicy.requiresApiKey(getBaseUrl()))
+ try {
+ attempt(tuning)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: OpenAiHttpException) {
+ if (e.statusCode != 400) throw e
+ val param = UnsupportedParameter.nameIn(e.body) ?: throw e
+ val adjusted = tuning.without(param) ?: throw e
+ context.logger.info(
+ "OpenAiBackend: server rejected '$param'; retrying once without it"
+ )
+ attempt(adjusted)
+ }
+ }
+
+ /**
+ * POST [messages] without streaming and return the reply text.
+ *
+ * @param messages the request's `messages[]` turns
+ * @param config sampling settings for this request
+ * @return the reply text, or "" when the server returned no choices
+ */
+ private suspend fun requestText(messages: JSONArray, config: LlmConfig): String {
+ var text = ""
+ withParameterRetry(config) { tuning ->
+ val body = OpenAiRequestBuilder.body(
+ messages, getModelName(), stream = false, config = config, tuning = tuning
+ )
+ text = http.post(
+ url = getBaseUrl() + CHAT_COMPLETIONS_PATH,
+ apiKey = readApiKeyOrBlank(),
+ body = body,
+ ) { reader -> extractText(JSONObject(reader.readText())) }
+ }
+ return text
+ }
+
+ /**
+ * List the models the configured server offers, filtered to plausible chat models.
+ *
+ * Completes with an empty list when the server answered with none, and exceptionally on a
+ * network/API failure — an HTTP one as an [OpenAiHttpException], so the caller can tell a
+ * refused key from an unreachable server.
+ */
+ fun listModels(): CompletableFuture> = listModels(readApiKeyOrBlank(), getBaseUrl())
+
+ /**
+ * List the models reachable with a caller-supplied credential and server.
+ *
+ * Lets the settings pane check a just-typed key or URL *before* either is persisted; the no-arg
+ * [listModels] reads what is on disk. Nothing here touches the stored key or its cache.
+ *
+ * @param apiKey the candidate key, or blank for a server that needs none; never logged
+ * @param baseUrl the candidate server, normalized by the caller
+ */
+ fun listModels(apiKey: String, baseUrl: String): CompletableFuture> {
+ val future = CompletableFuture>()
+ // close() cancels the scope, making launch a silent no-op; fail loudly instead.
+ if (!scope.isActive) {
+ future.completeExceptionally(IllegalStateException("OpenAI backend is closed"))
+ return future
+ }
+
+ val job = scope.launch {
+ try {
+ val models = fetchAvailableModels(apiKey.trim(), baseUrl)
+ context.logger.info("OpenAiBackend: ${models.size} chat models offered by $baseUrl")
+ future.complete(models)
+ } catch (e: CancellationException) {
+ future.cancel(true)
+ throw e
+ } catch (e: Exception) {
+ context.logger.warn("OpenAiBackend: model listing failed: ${e.message}")
+ future.completeExceptionally(e)
+ }
+ }
+ future.cancelJobOnCancel(job)
+
+ return future
+ }
+
+ /** The stored key as a possibly-empty string, for the calls that treat "no key" as valid. */
+ private fun readApiKeyOrBlank(): String = keyCache.read().orEmpty()
+
+ /** Fetch and filter `GET {baseUrl}/models`. Runs on the caller's (IO) coroutine. */
+ private fun fetchAvailableModels(apiKey: String, baseUrl: String): List {
+ val body = http.get(baseUrl + MODELS_PATH, apiKey)
+ val data = JSONObject(body).optJSONArray("data") ?: return emptyList()
+ val ids = (0 until data.length()).mapNotNull { index ->
+ data.optJSONObject(index)?.optString("id")?.takeIf { it.isNotBlank() }
+ }
+ return ChatModelFilter.chatModels(ids)
+ }
+
+ /** Cancel any in-flight generation (user pressed Stop). */
+ override fun cancelStreaming() {
+ currentJob?.cancel()
+ currentJob = null
+ }
+
+ /** Release all resources: cancel the backend scope, any in-flight request, and the key cache. */
+ fun close() {
+ currentJob?.cancel()
+ scope.cancel()
+ keyCache.clear()
+ }
+
+ /**
+ * Extract the reply text of a non-streamed response.
+ *
+ * @param response a `chat/completions` response
+ * @return the reply text, or "" when there are no choices
+ */
+ private fun extractText(response: JSONObject): String {
+ val choices = response.optJSONArray("choices") ?: return ""
+ return buildString {
+ for (i in 0 until choices.length()) {
+ val message = choices.optJSONObject(i)?.optJSONObject("message") ?: continue
+ append(message.optString("content"))
+ }
+ }
+ }
+
+ /**
+ * Turn a failure into one user-facing sentence.
+ *
+ * [OpenAiErrorFormatter] decides *what* went wrong; the wording comes from `strings.xml`. The
+ * raw HTTP error body stays on the logged exception and must never reach the transcript.
+ */
+ private fun formatErrorMessage(e: Exception): String {
+ val baseUrl = getBaseUrl()
+ return failureMessages.of(
+ OpenAiErrorFormatter.classify(
+ error = e,
+ modelName = getModelName(),
+ hasApiKey = readApiKeyOrBlank().isNotBlank(),
+ isOpenAiHost = BaseUrlPolicy.requiresApiKey(baseUrl),
+ )
+ )
+ }
+}
+
+/**
+ * Cancel [job] when this future is cancelled by its caller.
+ *
+ * [CompletableFuture.cancel] only flips the future's own state, so without this a caller that gives
+ * up leaves the HTTP fetch running to completion for a result nobody will read.
+ *
+ * @param job the coroutine producing this future's value
+ */
+private fun CompletableFuture.cancelJobOnCancel(job: Job) {
+ whenComplete { _, _ -> if (isCancelled) job.cancel() }
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt
new file mode 100644
index 00000000..42f28229
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt
@@ -0,0 +1,108 @@
+package com.itsaky.androidide.plugins.aiagentopenai.backend
+
+import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiHttpException
+import java.io.BufferedReader
+import java.net.HttpURLConnection
+import java.net.URL
+import org.json.JSONObject
+
+/**
+ * The HTTP transport this backend speaks: one POST that streams or does not, and one GET.
+ *
+ * [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 with a
+ * NoSuchMethodError. Kept apart from the backend so the backend is about generating, not sockets.
+ *
+ * @param connectTimeoutMs how long to wait for the connection itself
+ * @param readTimeoutMs how long a generation may take to answer
+ */
+internal class OpenAiHttpClient(
+ private val connectTimeoutMs: Int = CONNECT_TIMEOUT_MS,
+ private val readTimeoutMs: Int = READ_TIMEOUT_MS,
+) {
+
+ companion object {
+ private const val CONNECT_TIMEOUT_MS = 15_000
+
+ /** Generation can run for a while, so the read timeout is far longer than the connect. */
+ private const val READ_TIMEOUT_MS = 60_000
+ }
+
+ /**
+ * POST [body] to [url] and hand the response's reader to [readResponse].
+ *
+ * The connection is closed before this returns, whatever [readResponse] did with it.
+ *
+ * @param apiKey bearer token, or blank for a server that needs none
+ * @param sse true to ask for the server-sent-events stream
+ * @param onConnected receives the live connection, so a caller can disconnect it on cancellation
+ * @return whatever [readResponse] produced
+ * @throws OpenAiHttpException on a non-2xx answer, carrying the server's error body
+ */
+ fun post(
+ url: String,
+ apiKey: String,
+ body: JSONObject,
+ sse: Boolean = false,
+ onConnected: (HttpURLConnection) -> Unit = {},
+ readResponse: (BufferedReader) -> T,
+ ): T {
+ val conn = open(url, "POST", apiKey).apply {
+ readTimeout = readTimeoutMs
+ doOutput = true
+ setRequestProperty("Content-Type", "application/json")
+ if (sse) setRequestProperty("Accept", "text/event-stream")
+ }
+ onConnected(conn)
+ return try {
+ conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
+ conn.failIfNotOk()
+ conn.inputStream.bufferedReader().use(readResponse)
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /**
+ * GET [url] and return its response body.
+ *
+ * @param apiKey bearer token, or blank for a server that needs none
+ * @throws OpenAiHttpException on a non-2xx answer, carrying the server's error body
+ */
+ fun get(url: String, apiKey: String): String {
+ val conn = open(url, "GET", apiKey)
+ return try {
+ conn.failIfNotOk()
+ conn.inputStream.bufferedReader().use { it.readText() }
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /**
+ * Open a connection carrying the bearer token, when there is one.
+ *
+ * A header, never a query string: query strings leak into logs, proxies and crash reports. A
+ * blank key sends no header at all, which is what a local server expects. The read timeout
+ * starts at the connect budget; only a generation raises it.
+ */
+ private fun open(url: String, method: String, apiKey: String): HttpURLConnection =
+ (URL(url).openConnection() as HttpURLConnection).apply {
+ requestMethod = method
+ connectTimeout = connectTimeoutMs
+ readTimeout = connectTimeoutMs
+ if (apiKey.isNotBlank()) setRequestProperty("Authorization", "Bearer $apiKey")
+ }
+
+ /**
+ * Fail with the server's error body attached, so the status reaches its readers as a number
+ * rather than as text they have to match.
+ */
+ private fun HttpURLConnection.failIfNotOk() {
+ val code = responseCode
+ if (code !in 200..299) {
+ val body = errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
+ throw OpenAiHttpException(code, body)
+ }
+ }
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt
new file mode 100644
index 00000000..b95376f2
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt
@@ -0,0 +1,93 @@
+package com.itsaky.androidide.plugins.aiagentopenai.backend
+
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage
+import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Builds `chat/completions` request bodies.
+ *
+ * Pure — no Android types and no network — so the request shape, which is the thing a server 400s
+ * over, is unit-testable. `chat/completions` and not `responses`: it is the protocol every
+ * compatible server implements, which is the whole point of one backend for all of them.
+ */
+internal object OpenAiRequestBuilder {
+
+ private const val ROLE_SYSTEM = "system"
+ private const val ROLE_USER = "user"
+ private const val ROLE_ASSISTANT = "assistant"
+
+ /**
+ * Maps the conversation onto a real `messages[]` array.
+ *
+ * The system prompt leads as its own `system` turn — unlike the Gemini transport, which has no
+ * system role and fakes one with a user turn.
+ *
+ * @param history the conversation so far, oldest first
+ * @param prompt the current user turn, appended last
+ * @param systemPrompt the system prompt, or null to send none
+ * @return the `messages[]` array
+ */
+ fun messages(
+ history: List,
+ prompt: String,
+ systemPrompt: String?,
+ ): JSONArray {
+ val messages = JSONArray()
+ systemPrompt?.takeIf { it.isNotBlank() }?.let {
+ messages.put(message(ROLE_SYSTEM, it))
+ }
+ for (entry in history) {
+ val role = when (entry.role) {
+ ChatMessage.Role.USER -> ROLE_USER
+ ChatMessage.Role.ASSISTANT -> ROLE_ASSISTANT
+ ChatMessage.Role.SYSTEM -> ROLE_SYSTEM
+ // No native function calling here, so a tool result rides in as a user turn.
+ ChatMessage.Role.TOOL -> ROLE_USER
+ }
+ messages.put(message(role, entry.content))
+ }
+ messages.put(message(ROLE_USER, prompt))
+ return messages
+ }
+
+ /**
+ * Builds the request body for [messages].
+ *
+ * @param model the model id to request
+ * @param stream true to ask for the SSE token stream
+ * @param config supplies the token cap and temperature
+ * @param tuning decides which optional parameters are sent at all
+ * @return the request JSON
+ */
+ fun body(
+ messages: JSONArray,
+ model: String,
+ stream: Boolean,
+ config: LlmConfig,
+ tuning: RequestTuning,
+ ): JSONObject {
+ val body = JSONObject()
+ .put("model", model)
+ .put("messages", messages)
+ .put("stream", stream)
+
+ if (config.maxTokens > 0) {
+ body.put(tuning.tokenParam, config.maxTokens)
+ }
+ if (tuning.sendTemperature) {
+ body.put(RequestTuning.TEMPERATURE, config.temperature.toDouble())
+ }
+ config.stopSequences
+ ?.filter { it.isNotEmpty() }
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { body.put("stop", JSONArray(it)) }
+
+ return body
+ }
+
+ /** One `{role, content}` turn. */
+ private fun message(role: String, content: String): JSONObject =
+ JSONObject().put("role", role).put("content", content)
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt
new file mode 100644
index 00000000..10bc6cf0
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt
@@ -0,0 +1,131 @@
+package com.itsaky.androidide.plugins.aiagentopenai.backend
+
+import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiErrorFormatter
+
+/**
+ * Which optional parameters a `chat/completions` request should carry.
+ *
+ * Reasoning models reject `max_tokens` and several reject `temperature`, while older and
+ * third-party servers only understand `max_tokens` — so this is a per-request decision, not a
+ * constant. Pure, so every rule is unit-testable without a network.
+ *
+ * @param tokenParam the JSON key carrying the output-token cap
+ * @param sendTemperature false to omit `temperature` entirely
+ */
+internal data class RequestTuning(
+ val tokenParam: String,
+ val sendTemperature: Boolean,
+) {
+
+ /**
+ * The same tuning with [param] no longer sent, for the retry after a server rejected it.
+ *
+ * `max_completion_tokens` degrades to `max_tokens` rather than dropping the cap, because a
+ * server that refuses the new name is an older or third-party one that wants the old name.
+ *
+ * @param param the parameter the server named in its 400
+ * @return the adjusted tuning, or null when nothing about [param] can be changed
+ */
+ fun without(param: String): RequestTuning? = when (param) {
+ TEMPERATURE -> if (sendTemperature) copy(sendTemperature = false) else null
+ MAX_COMPLETION_TOKENS ->
+ if (tokenParam == MAX_COMPLETION_TOKENS) copy(tokenParam = MAX_TOKENS) else null
+ MAX_TOKENS ->
+ if (tokenParam == MAX_TOKENS) copy(tokenParam = MAX_COMPLETION_TOKENS) else null
+ else -> null
+ }
+
+ companion object {
+ const val TEMPERATURE = "temperature"
+ const val MAX_TOKENS = "max_tokens"
+ const val MAX_COMPLETION_TOKENS = "max_completion_tokens"
+
+ /**
+ * Model id prefixes whose models are reasoning models on `chat/completions`.
+ *
+ * Matched on the id's leading segment, so a vendor-prefixed OpenRouter id such as
+ * `openai/gpt-5.1` is recognised too.
+ */
+ private val REASONING_PREFIXES = listOf("gpt-5", "o1", "o3", "o4")
+
+ /**
+ * The tuning to start with for [model] on [baseUrl].
+ *
+ * OpenAI's own endpoint gets the modern `max_completion_tokens`; any other server gets
+ * `max_tokens`, which is what Ollama, LM Studio and llama-server implement. Either way an
+ * unsupported-parameter 400 is recovered from by [without], so this only has to be right
+ * often enough to avoid a wasted round trip.
+ *
+ * @param model the model id as configured
+ * @param requiresApiKey true when [baseUrl] is OpenAI's own API — see `BaseUrlPolicy`
+ */
+ fun forModel(model: String, requiresApiKey: Boolean): RequestTuning = RequestTuning(
+ tokenParam = if (requiresApiKey) MAX_COMPLETION_TOKENS else MAX_TOKENS,
+ sendTemperature = !isReasoningModel(model),
+ )
+
+ /**
+ * True when [model] names a reasoning model, which may reject `temperature`.
+ *
+ * Conservative by design: a false negative costs one retry, while a false positive would
+ * silently ignore the user's temperature on an ordinary model.
+ */
+ fun isReasoningModel(model: String): Boolean {
+ val id = model.trim().lowercase().substringAfterLast('/')
+ return REASONING_PREFIXES.any { prefix ->
+ // Guards against `o1ntel-chat`: a prefix match must end the id or a segment.
+ id == prefix || id.startsWith("$prefix-") || id.startsWith("$prefix.")
+ }
+ }
+ }
+}
+
+/**
+ * Finds the parameter an OpenAI-compatible server refused, so the request can be retried without
+ * it. Pure; the server bodies it reads are the ones a 400 carries.
+ */
+internal object UnsupportedParameter {
+
+ /** Parameters worth retrying without; anything else is a real request error. */
+ private val ADJUSTABLE = listOf(
+ RequestTuning.MAX_COMPLETION_TOKENS,
+ RequestTuning.MAX_TOKENS,
+ RequestTuning.TEMPERATURE,
+ )
+
+ /**
+ * Reads OpenAI's `error.param`, falling back to naming a parameter found in the message text.
+ *
+ * Both paths are gated on wording that says the parameter is unsupported, so a server
+ * complaining about a *value* does not trigger a pointless retry.
+ *
+ * @param body the response body of a 400
+ * @return the parameter to stop sending, or null when the failure is not about one
+ */
+ fun nameIn(body: String?): String? {
+ if (body.isNullOrBlank()) return null
+ val text = body.lowercase()
+ if (!soundsUnsupported(text)) return null
+
+ // The structured field is authoritative when the server supplies one.
+ paramField(body)?.let { param -> if (param in ADJUSTABLE) return param }
+
+ // Earliest mention, not longest match: OpenAI's own wording names the offender first and
+ // the replacement second ("'max_tokens' is not supported… Use 'max_completion_tokens'").
+ return ADJUSTABLE
+ .mapNotNull { param -> text.indexOf(param).takeIf { it >= 0 }?.let { it to param } }
+ .minByOrNull { it.first }
+ ?.second
+ }
+
+ /** The server's own `error.param`, lowercased, or null when there is no parseable one. */
+ private fun paramField(body: String): String? =
+ OpenAiErrorFormatter.errorObjectIn(body)?.optString("param")
+ ?.takeIf { it.isNotBlank() }?.lowercase()
+
+ /** True when the body says the parameter is not accepted, rather than that its value is bad. */
+ private fun soundsUnsupported(text: String): Boolean = listOf(
+ "unsupported", "not supported", "unrecognized", "unknown", "unexpected",
+ "is not permitted", "instead", "deprecated", "extra inputs",
+ ).any { text.contains(it) }
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/SseChunk.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/SseChunk.kt
new file mode 100644
index 00000000..5a828c25
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/SseChunk.kt
@@ -0,0 +1,147 @@
+package com.itsaky.androidide.plugins.aiagentopenai.backend
+
+import org.json.JSONObject
+
+/**
+ * Reads one line of an OpenAI-compatible SSE stream.
+ *
+ * Pure, so the framing — which is where a stream silently truncates or throws — is unit-testable
+ * without a server. Every compatible server emits the same `data: {json}` / `data: [DONE]` shape,
+ * but they disagree about what a chunk may carry beyond `delta.content`, and a chunk this parser
+ * does not understand is a reply the user never sees.
+ */
+internal object SseChunk {
+
+ private const val DATA_PREFIX = "data:"
+ private const val DONE_PAYLOAD = "[DONE]"
+
+ /** Longest slice of an unrecognised payload kept for the log; bodies can be large. */
+ private const val MAX_LOGGED_PAYLOAD = 200
+
+ /** What one SSE line means to the reader loop. */
+ sealed interface Event {
+
+ /** Visible reply text to append and hand to the caller. */
+ data class Token(val text: String) : Event
+
+ /**
+ * Thinking text, which is **not** part of the reply.
+ *
+ * Tracked rather than discarded: a model that spends its whole token budget reasoning
+ * produces a stream that is empty of content but far from empty, and saying "the request
+ * failed" there sends the user looking for a network problem that does not exist.
+ */
+ data class Reasoning(val text: String) : Event
+
+ /**
+ * The server reported a failure inside a 200 response.
+ *
+ * LM Studio and several others answer `stream: true` with HTTP 200 and then put the real
+ * error in the stream — context overflow, model unloaded. Without this the stream just
+ * ends empty.
+ */
+ data class Failure(val message: String) : Event
+
+ /** The turn ended for [reason], e.g. `length` when the token cap truncated it. */
+ data class Finish(val reason: String) : Event
+
+ /** The server said the stream is over; stop reading. */
+ data object Done : Event
+
+ /** Nothing to do: a comment, a keep-alive, a blank line, or an empty delta. */
+ data object Ignored : Event
+
+ /**
+ * The payload could not be used. [detail] is for the log, never for the transcript.
+ *
+ * Covers both unparseable JSON and a well-formed chunk in a shape this parser does not
+ * recognise — the second is what makes a silently empty reply diagnosable.
+ */
+ data class Malformed(val detail: String) : Event
+ }
+
+ /**
+ * Classifies [line].
+ *
+ * A malformed payload is reported rather than thrown: one bad chunk must not abort a stream
+ * that is otherwise producing tokens.
+ *
+ * @param line one raw line from the response body
+ * @return what the reader loop should do with it
+ */
+ fun parse(line: String): Event {
+ val trimmed = line.trim()
+ if (trimmed.isEmpty() || !trimmed.startsWith(DATA_PREFIX)) return Event.Ignored
+
+ val payload = trimmed.substringAfter(DATA_PREFIX).trim()
+ if (payload.isEmpty()) return Event.Ignored
+ if (payload == DONE_PAYLOAD) return Event.Done
+
+ val json = try {
+ JSONObject(payload)
+ } catch (e: Exception) {
+ return Event.Malformed("unparseable payload: ${e.message}")
+ }
+
+ // Checked before choices: an error chunk carries no usable content.
+ errorMessageOf(json)?.let { return Event.Failure(it) }
+
+ val choices = json.optJSONArray("choices")
+ // A usage-only or otherwise choice-less chunk is normal; an unrecognised one is not, and
+ // being told about it is the difference between diagnosing an empty reply and guessing.
+ if (choices == null) {
+ return if (json.has("usage")) {
+ Event.Ignored
+ } else {
+ Event.Malformed("no choices in payload: ${payload.take(MAX_LOGGED_PAYLOAD)}")
+ }
+ }
+
+ val content = StringBuilder()
+ val reasoning = StringBuilder()
+ var finishReason: String? = null
+ for (i in 0 until choices.length()) {
+ val choice = choices.optJSONObject(i) ?: continue
+ // `message` covers a server that ignores stream:true and answers in one shot.
+ val delta = choice.optJSONObject("delta") ?: choice.optJSONObject("message")
+ content.append(delta?.optString("content").orEmpty())
+ reasoning.append(reasoningOf(delta))
+ choice.optString("finish_reason").takeIf { it.isNotBlank() && it != "null" }
+ ?.let { finishReason = it }
+ }
+
+ return when {
+ content.isNotEmpty() -> Event.Token(content.toString())
+ reasoning.isNotEmpty() -> Event.Reasoning(reasoning.toString())
+ finishReason != null -> Event.Finish(finishReason!!)
+ else -> Event.Ignored
+ }
+ }
+
+ /**
+ * Thinking text from whichever field this server uses.
+ *
+ * `reasoning_content` is the DeepSeek/LM Studio spelling and `reasoning` the OpenRouter one;
+ * both appear in the wild on the same endpoint shape.
+ */
+ private fun reasoningOf(delta: JSONObject?): String {
+ if (delta == null) return ""
+ return delta.optString("reasoning_content").ifEmpty { delta.optString("reasoning") }
+ }
+
+ /**
+ * The server's error text, when the chunk is an error rather than a completion.
+ *
+ * Accepts both `{"error":{"message":…}}` and a bare `{"error":"…"}`, which compatible servers
+ * use interchangeably.
+ */
+ private fun errorMessageOf(json: JSONObject): String? {
+ if (!json.has("error")) return null
+ json.optJSONObject("error")?.let { error ->
+ return error.optString("message").takeIf { it.isNotBlank() }
+ ?: error.toString().take(MAX_LOGGED_PAYLOAD)
+ }
+ return json.optString("error").takeIf { it.isNotBlank() }
+ ?: "the server reported an unspecified error"
+ }
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt
new file mode 100644
index 00000000..1ffef892
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt
@@ -0,0 +1,218 @@
+package com.itsaky.androidide.plugins.aiagentopenai.errors
+
+import org.json.JSONObject
+import java.io.IOException
+
+/**
+ * What an OpenAI-compatible server said went wrong, as far as it could be determined.
+ *
+ * Every field is nullable because the failure may not be an API response at all — a DNS failure, a
+ * refused TCP connection to a LAN box, or a proxy's HTML error page reaches the same code path.
+ */
+data class OpenAiApiError(
+ /** HTTP status lifted from the `… HTTP : ` message, or null if there wasn't one. */
+ val httpStatus: Int?,
+ /** The server's machine-readable `error.code`, e.g. `invalid_api_key`, or null. */
+ val apiCode: String?,
+ /** The server's `error.type`, e.g. `invalid_request_error`, or null. */
+ val apiType: String?,
+ /** The server's human-readable `error.message`, collapsed to one line, or null. */
+ val apiMessage: String?,
+)
+
+/**
+ * An OpenAI-compatible failure reduced to the thing the user needs to be told.
+ *
+ * Carries no text: the wording lives in `strings.xml`, which also lets every branch be unit-tested
+ * without a Context. Any reason is already single-lined, length-capped, and never a JSON body.
+ */
+sealed interface OpenAiFailure {
+
+ /** The model is unknown to this server (HTTP 404, or `model_not_found`). */
+ data class ModelUnavailable(val modelName: String) : OpenAiFailure
+
+ /** Rate limit or spent quota (HTTP 429). The key itself is fine. */
+ data object QuotaExceeded : OpenAiFailure
+
+ /** The account has no credit left (HTTP 429 whose body names billing or credit). */
+ data object BillingRequired : OpenAiFailure
+
+ /** The credential was refused (HTTP 401, or `invalid_api_key`). */
+ data object KeyRefused : OpenAiFailure
+
+ /** The server needs a key and none was configured (HTTP 401 with nothing sent). */
+ data object KeyMissing : OpenAiFailure
+
+ /** The key is valid but not allowed to use this model or endpoint (HTTP 403). */
+ data object KeyForbidden : OpenAiFailure
+
+ /** HTTP 400 about the request rather than the credential. */
+ data class RequestRejected(val reason: String?) : OpenAiFailure
+
+ /** Server-side outage (HTTP 5xx). Says nothing about the key or the model. */
+ data class ServiceUnavailable(val httpStatus: Int) : OpenAiFailure
+
+ /** An HTTP status with no specific handling. */
+ data class Unexpected(val httpStatus: Int, val reason: String?) : OpenAiFailure
+
+ /**
+ * No response at all — no network, DNS failure, timeout, or nothing listening.
+ *
+ * Distinguished from [Unreachable] because a custom LAN server that is simply not running is
+ * the single most likely failure for an ADFA-3452 user, and "check the server is running" is
+ * better advice than "check your internet connection".
+ */
+ data object ServerNotRunning : OpenAiFailure
+
+ /** No response and the server was OpenAI itself, i.e. the device has no route out. */
+ data object Unreachable : OpenAiFailure
+
+ /**
+ * The server streamed successfully but produced no reply text.
+ *
+ * Its own state because the request did **not** fail: reporting a network-shaped error here
+ * sends the user hunting for a connection problem that does not exist.
+ *
+ * @param skippedChunks payloads the parser could not use, which is the diagnostic
+ */
+ data class EmptyReply(val skippedChunks: Int) : OpenAiFailure
+
+ /**
+ * The model produced only thinking text and never got to an answer — almost always the token
+ * cap being consumed by reasoning.
+ */
+ data object ReasoningOnly : OpenAiFailure
+
+ /** The token cap cut the turn off before any reply text arrived (`finish_reason: length`). */
+ data object TruncatedBeforeReply : OpenAiFailure
+
+ /** Everything else, including failures that never reached the network. */
+ data class Failed(val reason: String?) : OpenAiFailure
+}
+
+/**
+ * Classifies an OpenAI-compatible failure so it can be reported as one translated sentence.
+ *
+ * The log keeps the full body; **no [OpenAiFailure] ever carries a JSON payload** — putting the raw
+ * error body in the chat transcript is the bug this class exists to prevent.
+ */
+object OpenAiErrorFormatter {
+
+ /**
+ * Matches the status in an `OpenAI HTTP 404: {…}` message. A fallback: a status that arrived as
+ * an [OpenAiHttpException] field is read from the field, never from text.
+ */
+ private val HTTP_STATUS = Regex("""HTTP (\d{3})""")
+
+ /** Longest slice of the server's own wording carried onward; keeps a stray body out of the UI. */
+ private const val MAX_ECHOED_REASON = 160
+
+ /**
+ * Pull the status code and, when the message carries a JSON error body, the server's own
+ * `code`/`type`/`message` out of it. A non-JSON, truncated or absent body yields nulls rather
+ * than throwing, because this runs while already handling a failure.
+ *
+ * @param rawMessage the throwable message, typically `OpenAI HTTP : `
+ */
+ fun parse(rawMessage: String?): OpenAiApiError {
+ val raw = rawMessage.orEmpty()
+ val error = errorObjectIn(raw)
+
+ return OpenAiApiError(
+ httpStatus = HTTP_STATUS.find(raw)?.groupValues?.get(1)?.toIntOrNull(),
+ apiCode = error?.optString("code")?.takeIf { it.isNotBlank() },
+ apiType = error?.optString("type")?.takeIf { it.isNotBlank() },
+ apiMessage = error?.optString("message")?.takeIf { it.isNotBlank() }?.toSingleLine(),
+ )
+ }
+
+ /**
+ * Decide what to tell the user about [error].
+ *
+ * @param error the failure as thrown; its message is parsed, and its type distinguishes a
+ * transport problem from an API refusal when there is no status to read
+ * @param modelName the model the request was for, so an unknown-model failure can name it
+ * @param hasApiKey whether a key was actually sent, to tell "wrong key" from "no key"
+ * @param isOpenAiHost whether the target was OpenAI itself, which changes the no-answer advice
+ */
+ fun classify(
+ error: Throwable,
+ modelName: String,
+ hasApiKey: Boolean,
+ isOpenAiHost: Boolean,
+ ): OpenAiFailure {
+ val parsed = parse(error.message)
+ // The transport reports its status as a field; the pattern below only has to cover a
+ // failure that reached here some other way.
+ val status = (error as? OpenAiHttpException)?.statusCode ?: parsed.httpStatus
+
+ return when {
+ status == 404 || parsed.apiCode == "model_not_found" ->
+ OpenAiFailure.ModelUnavailable(modelName)
+
+ status == 429 && parsed.mentionsBilling() -> OpenAiFailure.BillingRequired
+
+ status == 429 || parsed.apiCode == "rate_limit_exceeded" ->
+ OpenAiFailure.QuotaExceeded
+
+ status == 401 && !hasApiKey -> OpenAiFailure.KeyMissing
+
+ status == 401 || parsed.apiCode == "invalid_api_key" -> OpenAiFailure.KeyRefused
+
+ status == 403 -> OpenAiFailure.KeyForbidden
+
+ status == 400 -> OpenAiFailure.RequestRejected(safeReason(parsed, error))
+
+ status != null && status in 500..599 -> OpenAiFailure.ServiceUnavailable(status)
+
+ status != null -> OpenAiFailure.Unexpected(status, safeReason(parsed, error))
+
+ // No status at all: the request never got an answer.
+ error is IOException ->
+ if (isOpenAiHost) OpenAiFailure.Unreachable else OpenAiFailure.ServerNotRunning
+
+ else -> OpenAiFailure.Failed(safeReason(parsed, error))
+ }
+ }
+
+ /** True when a 429 is about money rather than request rate. */
+ private fun OpenAiApiError.mentionsBilling(): Boolean {
+ val text = "${apiCode.orEmpty()} ${apiType.orEmpty()} ${apiMessage.orEmpty()}".lowercase()
+ return listOf("billing", "credit", "quota", "insufficient_quota", "payment")
+ .any { text.contains(it) }
+ }
+
+ /**
+ * The server's own explanation, but only when it is short and safe to show.
+ *
+ * Falls back to the throwable's message when there was no JSON body, and never when that
+ * message contains one — carrying a `{` onward is the bug this class exists to prevent.
+ *
+ * @return the reason, or null when there is nothing showable
+ */
+ private fun safeReason(parsed: OpenAiApiError, error: Throwable): String? {
+ val reason = parsed.apiMessage
+ ?: error.message?.takeIf { !it.contains('{') }?.toSingleLine()
+ ?: return null
+ if (reason.isBlank() || reason.length > MAX_ECHOED_REASON) return null
+ return reason
+ }
+
+ /**
+ * The `error` object of a server error body, wherever it starts inside [raw].
+ *
+ * Shared with the unsupported-parameter recovery, which reads a field of the same object out of
+ * the same kind of body; two hand-rolled copies of "find the brace, hope it parses" is one too
+ * many. Never throws: it runs while a failure is already being handled.
+ *
+ * @param raw a throwable message or a raw response body
+ */
+ internal fun errorObjectIn(raw: String?): JSONObject? {
+ val start = raw?.indexOf('{') ?: return null
+ if (start < 0) return null
+ return runCatching { JSONObject(raw.substring(start)).optJSONObject("error") }.getOrNull()
+ }
+
+ /** Collapse whitespace runs so a pretty-printed JSON string can't span lines in the UI. */
+ private fun String.toSingleLine(): String = trim().replace(Regex("""\s+"""), " ")
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt
new file mode 100644
index 00000000..803e1bff
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt
@@ -0,0 +1,79 @@
+package com.itsaky.androidide.plugins.aiagentopenai.errors
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiagentopenai.R
+
+/**
+ * The wording for an [OpenAiFailure].
+ *
+ * `context.androidContext` is plugin-scoped, so this plugin's string ids resolve here. Separate
+ * from the backend, which decides *what* failed and has no business also owning how it is phrased.
+ *
+ * @param context this plugin's context, whose resources carry the strings
+ * @param baseUrl the configured server, named in the "server not running" advice
+ */
+internal class OpenAiFailureMessages(
+ private val context: PluginContext,
+ private val baseUrl: () -> String,
+) {
+
+ /**
+ * One user-facing sentence for [failure]. A failed lookup degrades to the generic message
+ * rather than throwing out of an error handler.
+ */
+ fun of(failure: OpenAiFailure): String = try {
+ val resources = context.androidContext
+ when (failure) {
+ is OpenAiFailure.ModelUnavailable ->
+ resources.getString(R.string.openai_error_model_unavailable, failure.modelName)
+
+ OpenAiFailure.QuotaExceeded ->
+ resources.getString(R.string.openai_error_quota)
+
+ OpenAiFailure.BillingRequired ->
+ resources.getString(R.string.openai_error_billing)
+
+ OpenAiFailure.KeyRefused ->
+ resources.getString(R.string.openai_error_key_refused)
+
+ OpenAiFailure.KeyMissing ->
+ resources.getString(R.string.openai_error_key_missing)
+
+ OpenAiFailure.KeyForbidden ->
+ resources.getString(R.string.openai_error_key_forbidden)
+
+ is OpenAiFailure.RequestRejected -> failure.reason?.let {
+ resources.getString(R.string.openai_error_request_rejected_reason, it)
+ } ?: resources.getString(R.string.openai_error_request_rejected)
+
+ is OpenAiFailure.ServiceUnavailable ->
+ resources.getString(R.string.openai_error_service_unavailable, failure.httpStatus)
+
+ is OpenAiFailure.Unexpected -> failure.reason?.let {
+ resources.getString(R.string.openai_error_unexpected_reason, failure.httpStatus, it)
+ } ?: resources.getString(R.string.openai_error_unexpected, failure.httpStatus)
+
+ is OpenAiFailure.EmptyReply ->
+ resources.getString(R.string.openai_error_empty_reply)
+
+ OpenAiFailure.ReasoningOnly ->
+ resources.getString(R.string.openai_error_reasoning_only)
+
+ OpenAiFailure.TruncatedBeforeReply ->
+ resources.getString(R.string.openai_error_truncated)
+
+ OpenAiFailure.ServerNotRunning ->
+ resources.getString(R.string.openai_error_server_not_running, baseUrl())
+
+ OpenAiFailure.Unreachable ->
+ resources.getString(R.string.openai_error_unreachable)
+
+ is OpenAiFailure.Failed -> failure.reason?.let {
+ resources.getString(R.string.openai_error_failed_reason, it)
+ } ?: resources.getString(R.string.openai_error_failed)
+ }
+ } catch (e: Exception) {
+ context.logger.error("OpenAiFailureMessages: could not resolve a string for $failure", e)
+ "The request to the AI server failed."
+ }
+}
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiHttpException.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiHttpException.kt
new file mode 100644
index 00000000..89a6fca5
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiHttpException.kt
@@ -0,0 +1,19 @@
+package com.itsaky.androidide.plugins.aiagentopenai.errors
+
+import java.io.IOException
+
+/**
+ * A non-2xx answer from an OpenAI-compatible server.
+ *
+ * The status and the body are fields rather than something a reader digs back out of the message:
+ * the settings pane's verdict and the retry-without-a-rejected-parameter recovery both need the
+ * status, and reading it out of formatted text made the wording of a log line a cross-module
+ * contract that a reword would silently break.
+ *
+ * @param statusCode the HTTP status the server answered with
+ * @param body the server's error body; never shown to the user unfiltered
+ */
+class OpenAiHttpException(
+ val statusCode: Int,
+ val body: String,
+) : IOException("OpenAI HTTP $statusCode: $body")
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/logging/LogTags.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/logging/LogTags.kt
new file mode 100644
index 00000000..0cffbbab
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/logging/LogTags.kt
@@ -0,0 +1,8 @@
+package com.itsaky.androidide.plugins.aiagentopenai.logging
+
+/**
+ * Prefix on every logcat tag this plugin writes, so a line names the plugin that emitted it — every
+ * AI feature shares the host IDE's process, where a bare `SecureApiKeyStore` tag names no `.cgp`.
+ * Tags read `"$LOG_PREFIX.ClassName"`, so `adb logcat -s AiAgentOpenAi.*` is this plugin's log.
+ */
+internal const val LOG_PREFIX = "AiAgentOpenAi"
diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt
new file mode 100644
index 00000000..5ee440ee
--- /dev/null
+++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt
@@ -0,0 +1,324 @@
+package com.itsaky.androidide.plugins.aiagentopenai.plugin
+
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.PluginLifecycleListener
+import com.itsaky.androidide.plugins.aiagentopenai.backend.OpenAiBackend
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+
+/**
+ * Registers the OpenAI-compatible backend with AI Core's inference router.
+ *
+ * Owns the transport *and* the UI that configures it: the backend names a settings Fragment that
+ * ships in this plugin, which whichever screen offers a backend selector mounts under its own
+ * selector. AI Core owns routing; nothing outside this plugin handles the API key.
+ */
+class OpenAiPlugin : IPlugin, DocumentationExtension {
+
+ private lateinit var context: PluginContext
+ private var backend: OpenAiBackend? = null
+
+ /** True once [backend] is registered with the router, so re-registration is idempotent. */
+ @Volatile private var registered = false
+
+ companion object {
+ const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentopenai"
+
+ /** Provider of [LlmInferenceService]; this plugin is useless without it. */
+ private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore"
+
+ private const val TOOLTIP_TAG_PLUGIN = "plugin_ai_backend_openai"
+
+ /**
+ * Category the host registers this plugin's tooltips under. Must be `"plugin_"` + the full
+ * plugin id, or a long-press renders the literal string `n/a`.
+ */
+ const val TOOLTIP_CATEGORY = "plugin_$PLUGIN_ID"
+
+ // Tags for the controls on this backend's settings pane (see OpenAiSettingsFragment).
+ const val TOOLTIP_TAG_SETTINGS_SERVER = "ai_openai_server"
+ const val TOOLTIP_TAG_SETTINGS_PRESET = "ai_openai_preset"
+ const val TOOLTIP_TAG_SETTINGS_KEY = "ai_openai_key"
+ const val TOOLTIP_TAG_SETTINGS_MODEL = "ai_openai_model"
+ const val TOOLTIP_TAG_SETTINGS_TEST = "ai_openai_test_connection"
+ const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_openai_get_key"
+
+ @Volatile
+ private var pluginContext: PluginContext? = null
+
+ @Volatile
+ private var activeBackend: OpenAiBackend? = null
+
+ /** This plugin's context, for the settings pane the backend contributes. */
+ fun getContext(): PluginContext? = pluginContext
+
+ /**
+ * The live backend, so the settings pane can test a connection and list models against the
+ * same transport that serves generation. Null before activation and after disposal.
+ */
+ fun getBackend(): OpenAiBackend? = activeBackend
+ }
+
+ /**
+ * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so
+ * [activate] may run before AI Core has published its service; this closes that race instead
+ * of polling for it.
+ */
+ private val aiCoreLifecycle = object : PluginLifecycleListener {
+ override fun onPluginActivated(pluginId: String) {
+ if (pluginId == AI_CORE_PLUGIN_ID) registerBackend()
+ }
+
+ override fun onPluginDeactivated(pluginId: String) {
+ // The router went away and took the registration with it; allow a fresh one.
+ if (pluginId == AI_CORE_PLUGIN_ID) registered = false
+ }
+
+ override fun onPluginUninstalled(pluginId: String) {
+ if (pluginId == AI_CORE_PLUGIN_ID) registered = false
+ }
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ return try {
+ this.context = context
+ // Published for the settings pane, which the hosting screen constructs directly.
+ pluginContext = context
+ context.logger.info("OpenAiPlugin: Plugin initialized successfully")
+ true
+ } catch (e: Exception) {
+ context.logger.error("OpenAiPlugin: Plugin initialization failed", e)
+ false
+ }
+ }
+
+ override fun activate(): Boolean {
+ context.logger.info("OpenAiPlugin: Activating plugin")
+
+ return try {
+ // A half-failed activation can leave a backend behind; keep at most one live.
+ releaseBackend()
+
+ val openAi = OpenAiBackend(context)
+ backend = openAi
+ activeBackend = openAi
+
+ // Decrypt the key off-thread now, so a main-thread isAvailable() can't say "no key".
+ openAi.warmKeyCache()
+
+ // Listen first, then try: a listener added after a successful attempt would still be
+ // needed for a later AI Core restart, and one added before costs nothing.
+ context.addPluginLifecycleListener(aiCoreLifecycle)
+ if (!registerBackend()) {
+ context.logger.info(
+ "OpenAiPlugin: AI Core is not active yet; will register when it activates"
+ )
+ }
+
+ true
+ } catch (e: Exception) {
+ context.logger.error("OpenAiPlugin: Activation failed", e)
+ false
+ }
+ }
+
+ /**
+ * Registers the backend with AI Core's router, if the router is reachable.
+ *
+ * @return true when the backend is registered (now or already), false when AI Core is absent
+ */
+ private fun registerBackend(): Boolean {
+ if (registered) return true
+ val openAi = backend ?: return false
+
+ val service = resolveInferenceService()
+ if (service == null) {
+ context.logger.debug("OpenAiPlugin: LlmInferenceService not available yet")
+ return false
+ }
+
+ return try {
+ service.registerBackend(openAi)
+ registered = true
+ context.logger.info("OpenAiPlugin: Registered '${openAi.getId()}' backend with AI Core")
+ true
+ } catch (e: Exception) {
+ context.logger.error("OpenAiPlugin: Could not register the OpenAI backend", e)
+ false
+ }
+ }
+
+ /**
+ * Resolves AI Core's router, preferring the process-global registry and falling back to the
+ * provider-scoped lookup so a registry cleared by another plugin is not fatal.
+ */
+ private fun resolveInferenceService(): LlmInferenceService? = try {
+ SharedServices.get(LlmInferenceService::class.java)
+ ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java)
+ } catch (e: Exception) {
+ context.logger.warn("OpenAiPlugin: Could not resolve LlmInferenceService: ${e.message}")
+ null
+ }
+
+ override fun deactivate(): Boolean {
+ context.logger.info("OpenAiPlugin: Deactivating plugin")
+
+ return try {
+ context.removePluginLifecycleListener(aiCoreLifecycle)
+
+ val openAi = backend
+ if (openAi != null && registered) {
+ resolveInferenceService()?.unregisterBackend(openAi.getId())
+ registered = false
+ context.logger.info("OpenAiPlugin: Unregistered '${openAi.getId()}' backend")
+ }
+
+ // A disabled plugin must not keep the decrypted key on the host heap.
+ releaseBackend()
+
+ true
+ } catch (e: Exception) {
+ context.logger.error("OpenAiPlugin: Deactivation failed", e)
+ false
+ }
+ }
+
+ /**
+ * Cancels in-flight requests, drops the decrypted key from the heap, and clears the published
+ * backend. Idempotent, so a [deactivate] followed by [dispose] closes nothing twice.
+ */
+ private fun releaseBackend() {
+ backend?.close()
+ backend = null
+ activeBackend = null
+ registered = false
+ }
+
+ override fun dispose() {
+ context.logger.info("OpenAiPlugin: Disposing plugin")
+
+ releaseBackend()
+ pluginContext = null
+ context.logger.info("OpenAiPlugin: Released OpenAI backend")
+ }
+
+ override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID"
+
+ override fun getTooltipEntries(): List = listOf(
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_PLUGIN,
+ summary = "Sends prompts to OpenAI, or to any server that speaks the same protocol. Needs a network connection.",
+ detail = """
+
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