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

AI Agent OpenAI Plugin

+ +

Executive overview

+

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

+

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

+

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

+ +

One backend, many servers

+

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

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

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

+ +

Core functionality

+ + +

Technical architecture

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

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

+ +

Usage

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

Cost

+

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

+ +

Key benefits

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

AI Agent OpenAI — Cloud and Self-Hosted Inference

+ +

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

+ +

One backend, many servers

+ +

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

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

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

+ +

Setup

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

Cost, and the free options

+ +

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

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

Privacy

+

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

+ +

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

+ +

Reasoning models

+

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

+ +

Troubleshooting

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