From 52101c424189ad5f90ee79658259bcb738901a93 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 1 Oct 2026 19:09:44 -0700 Subject: [PATCH 01/10] ADFA-6311 | Copy AI-Agent-OpenAI to AI-Agent-Claude, renamed only Mechanical: plugins/AI-Agent-OpenAI copied verbatim, with the package, class names, resource prefixes, plugin name and Keystore alias renamed from openai to claude. No behaviour differs from the source plugin yet, so the next commit's diff is exactly what Claude needs. Co-Authored-By: Claude Opus 5.5 --- plugins/AI-Agent-Claude/.gitignore | 3 + plugins/AI-Agent-Claude/README.md | 154 +++ plugins/AI-Agent-Claude/ai-agent-claude.html | 173 +++ plugins/AI-Agent-Claude/build.gradle.kts | 80 ++ plugins/AI-Agent-Claude/gradle.properties | 10 + plugins/AI-Agent-Claude/proguard-rules.pro | 21 + plugins/AI-Agent-Claude/settings.gradle.kts | 35 + .../src/main/AndroidManifest.xml | 77 ++ .../src/main/assets/docs/index.html | 155 +++ .../src/main/assets/icon_day.png | Bin 0 -> 11404 bytes .../src/main/assets/icon_night.png | Bin 0 -> 10800 bytes .../aiagentclaude/backend/ClaudeBackend.kt | 939 ++++++++++++++ .../backend/ClaudeEmbeddingProtocol.kt | 123 ++ .../aiagentclaude/backend/ClaudeHttpClient.kt | 117 ++ .../backend/ClaudeRequestBuilder.kt | 103 ++ .../backend/ClaudeToolProtocol.kt | 212 +++ .../aiagentclaude/backend/ModelCatalog.kt | 20 + .../backend/ModelCatalogFilter.kt | 82 ++ .../aiagentclaude/backend/NetworkTags.kt | 38 + .../aiagentclaude/backend/RequestTuning.kt | 169 +++ .../plugins/aiagentclaude/backend/SseChunk.kt | 163 +++ .../errors/ClaudeErrorFormatter.kt | 271 ++++ .../errors/ClaudeFailureMessages.kt | 81 ++ .../errors/ClaudeHttpException.kt | 19 + .../errors/CredentialFailureLog.kt | 63 + .../plugins/aiagentclaude/logging/LogTags.kt | 8 + .../aiagentclaude/plugin/ClaudePlugin.kt | 345 +++++ .../preferences/ClaudePreferences.kt | 108 ++ .../prompt/ClaudeSystemPrompt.kt | 110 ++ .../aiagentclaude/security/ApiKeyCache.kt | 120 ++ .../security/SecureApiKeyStore.kt | 17 + .../aiagentclaude/settings/BaseUrlPolicy.kt | 234 ++++ .../aiagentclaude/settings/CatalogResult.kt | 33 + .../settings/ClaudeCatalogGateway.kt | 122 ++ .../settings/ClaudeKeyOnboarding.kt | 19 + .../settings/ClaudeSettingsFragment.kt | 1146 +++++++++++++++++ .../settings/ClaudeSettingsViewModel.kt | 527 ++++++++ .../settings/ConnectionVerification.kt | 125 ++ .../aiagentclaude/settings/ModelSelection.kt | 33 + .../settings/RememberedModels.kt | 46 + .../aiagentclaude/settings/ServerPreset.kt | 54 + .../plugins/aiagentclaude/ui/PaneStyling.kt | 77 ++ .../ui/SecretRevealController.kt | 106 ++ .../src/main/res/color/plugin_box_stroke.xml | 8 + .../color/plugin_button_filled_container.xml | 6 + .../color/plugin_button_filled_content.xml | 6 + .../res/color/plugin_button_filled_ripple.xml | 8 + .../color/plugin_button_outlined_content.xml | 6 + .../color/plugin_button_outlined_ripple.xml | 8 + .../color/plugin_button_outlined_stroke.xml | 6 + .../src/main/res/drawable/ic_dropdown.xml | 10 + .../src/main/res/drawable/ic_key_rejected.xml | 10 + .../main/res/drawable/ic_key_unchecked.xml | 10 + .../src/main/res/drawable/ic_key_verified.xml | 10 + .../src/main/res/drawable/ic_visibility.xml | 10 + .../main/res/drawable/ic_visibility_off.xml | 10 + .../res/layout/fragment_claude_settings.xml | 303 +++++ .../src/main/res/layout/item_dropdown.xml | 15 + .../src/main/res/values-night/colors.xml | 35 + .../src/main/res/values/colors.xml | 41 + .../src/main/res/values/dimens.xml | 24 + .../src/main/res/values/strings.xml | 129 ++ .../src/main/res/values/styles.xml | 149 +++ .../backend/ClaudeBackendTest.kt | 35 + .../backend/ClaudeEmbeddingProtocolTest.kt | 131 ++ .../backend/ClaudeRequestBuilderTest.kt | 195 +++ .../backend/ClaudeToolProtocolTest.kt | 201 +++ .../backend/ModelCatalogFilterTest.kt | 118 ++ .../aiagentclaude/backend/NetworkTagsTest.kt | 78 ++ .../backend/RequestTuningTest.kt | 188 +++ .../aiagentclaude/backend/SseChunkTest.kt | 190 +++ .../errors/ClaudeCredentialProblemTest.kt | 52 + .../errors/ClaudeErrorFormatterTest.kt | 146 +++ .../errors/CredentialFailureTagTest.kt | 43 + .../prompt/ClaudeSystemPromptTest.kt | 100 ++ .../settings/BaseUrlPolicyTest.kt | 240 ++++ .../settings/ConnectionVerificationTest.kt | 158 +++ .../settings/ModelSelectionTest.kt | 58 + .../settings/RememberedModelsTest.kt | 52 + 79 files changed, 9127 insertions(+) create mode 100644 plugins/AI-Agent-Claude/.gitignore create mode 100644 plugins/AI-Agent-Claude/README.md create mode 100644 plugins/AI-Agent-Claude/ai-agent-claude.html create mode 100644 plugins/AI-Agent-Claude/build.gradle.kts create mode 100644 plugins/AI-Agent-Claude/gradle.properties create mode 100644 plugins/AI-Agent-Claude/proguard-rules.pro create mode 100644 plugins/AI-Agent-Claude/settings.gradle.kts create mode 100644 plugins/AI-Agent-Claude/src/main/AndroidManifest.xml create mode 100644 plugins/AI-Agent-Claude/src/main/assets/docs/index.html create mode 100644 plugins/AI-Agent-Claude/src/main/assets/icon_day.png create mode 100644 plugins/AI-Agent-Claude/src/main/assets/icon_night.png create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeEmbeddingProtocol.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeHttpClient.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilder.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocol.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalog.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalogFilter.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTags.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/RequestTuning.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/SseChunk.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatter.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeFailureMessages.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeHttpException.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureLog.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/logging/LogTags.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/preferences/ClaudePreferences.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/ApiKeyCache.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/SecureApiKeyStore.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/BaseUrlPolicy.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/CatalogResult.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeCatalogGateway.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeKeyOnboarding.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsViewModel.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ConnectionVerification.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ModelSelection.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/RememberedModels.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ServerPreset.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/PaneStyling.kt create mode 100644 plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/SecretRevealController.kt create mode 100644 plugins/AI-Agent-Claude/src/main/res/color/plugin_box_stroke.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/color/plugin_button_filled_container.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/color/plugin_button_filled_content.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/color/plugin_button_filled_ripple.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/color/plugin_button_outlined_content.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/color/plugin_button_outlined_ripple.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/color/plugin_button_outlined_stroke.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/drawable/ic_dropdown.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/drawable/ic_key_rejected.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/drawable/ic_key_unchecked.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/drawable/ic_key_verified.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/drawable/ic_visibility.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/drawable/ic_visibility_off.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/layout/fragment_claude_settings.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/layout/item_dropdown.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/values-night/colors.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/values/colors.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/values/dimens.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/values/strings.xml create mode 100644 plugins/AI-Agent-Claude/src/main/res/values/styles.xml create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackendTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeEmbeddingProtocolTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilderTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocolTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalogFilterTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTagsTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/RequestTuningTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/SseChunkTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeCredentialProblemTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatterTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureTagTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPromptTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/BaseUrlPolicyTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ConnectionVerificationTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ModelSelectionTest.kt create mode 100644 plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/RememberedModelsTest.kt diff --git a/plugins/AI-Agent-Claude/.gitignore b/plugins/AI-Agent-Claude/.gitignore new file mode 100644 index 00000000..5380c6d5 --- /dev/null +++ b/plugins/AI-Agent-Claude/.gitignore @@ -0,0 +1,3 @@ +**/.cxx/ +build-output.log +**/.kotlin/ diff --git a/plugins/AI-Agent-Claude/README.md b/plugins/AI-Agent-Claude/README.md new file mode 100644 index 00000000..6a62c3bc --- /dev/null +++ b/plugins/AI-Agent-Claude/README.md @@ -0,0 +1,154 @@ +# 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`, `Speech-to-Text` +and `Vector-Search` 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 plugins/AI-Agent-OpenAI +../../gradlew assemblePlugin # release -> build/plugin/ai-agent-claude.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 +key entry collapses to one muted line rather than showing an empty, +mandatory-looking field for a server that wants no credential. That happens whether +or not a key is already stored; a stored one leaves only **Remove**, so a key saved +for another server can still be cleared from here. 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. + +**A key is bound to the server it was saved for.** The base URL is recorded next to +the key (`KEY_API_KEY_URL`) and `readApiKeyOrBlank()` sends nothing when it does not +match the configured server's origin, so pointing the URL at a local or LAN address +after configuring OpenAI cannot put that bearer token on the network in the clear. +A key stored before the origin was recorded is still sent, since it cannot be shown +to belong elsewhere. The connection test applies the same rule. + +`security/SecureApiKeyStore.kt` holds only this plugin's Keystore alias +(`cotg_ai_openai_key_v1`); the AES/GCM itself is the IDE's `KeystoreSecretStore` +(`plugin-api`, since **26.36** — hence this plugin's `min_ide_version`), so there +is one implementation in the process rather than a copy per plugin. The **alias** +is deliberately not shared: 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 plugins never read each other's ciphertext, so they have no reason to share +one. + +## 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-claude.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/aiagentclaude/`. + +- `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/ModelCatalogFilter.kt` — splits one catalog into the chat and embedding pickers (pure) +- `backend/OpenAiEmbeddingProtocol.kt` — the `/v1/embeddings` body, batching and index-ordered reply (pure) +- `errors/OpenAiErrorFormatter.kt` — turns a failure into one translated sentence +- `security/SecureApiKeyStore.kt` — this plugin's Keystore alias, over the IDE's `KeystoreSecretStore` +- `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 177 JVM tests. + +## License + +GPL-3.0 — same as AndroidIDE / CodeOnTheGo. diff --git a/plugins/AI-Agent-Claude/ai-agent-claude.html b/plugins/AI-Agent-Claude/ai-agent-claude.html new file mode 100644 index 00000000..8403d0f9 --- /dev/null +++ b/plugins/AI-Agent-Claude/ai-agent-claude.html @@ -0,0 +1,173 @@ + + + + + +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.
SecureApiKeyStoreBinds this plugin's own + Keystore alias to the IDE's KeystoreSecretStore, which + AES/GCM-encrypts the API key.
+

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/plugins/AI-Agent-Claude/build.gradle.kts b/plugins/AI-Agent-Claude/build.gradle.kts new file mode 100644 index 00000000..43ba8183 --- /dev/null +++ b/plugins/AI-Agent-Claude/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + id("com.android.application") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "ai-agent-claude" +} + +android { + namespace = "com.itsaky.androidide.plugins.aiagentclaude" + compileSdk = 36 + + defaultConfig { + applicationId = "com.itsaky.androidide.plugins.aiagentclaude" + minSdk = 33 + targetSdk = 36 + versionCode = 2 + versionName = "1.1.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.21") + 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") +} + +// No SecureApiKeyStore parity check any more: the AES/GCM core is the host's KeystoreSecretStore +// (plugin-api), so there is one implementation in the process rather than copies to keep in step. + +// 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/plugins/AI-Agent-Claude/gradle.properties b/plugins/AI-Agent-Claude/gradle.properties new file mode 100644 index 00000000..fcd58cda --- /dev/null +++ b/plugins/AI-Agent-Claude/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/plugins/AI-Agent-Claude/proguard-rules.pro b/plugins/AI-Agent-Claude/proguard-rules.pro new file mode 100644 index 00000000..7f9d6673 --- /dev/null +++ b/plugins/AI-Agent-Claude/proguard-rules.pro @@ -0,0 +1,21 @@ +# AI Agent OpenAI Plugin ProGuard Rules + +# Keep plugin entry point +-keep public class com.itsaky.androidide.plugins.aiagentclaude.plugin.ClaudePlugin { + public ; +} + +# Keep the backend: its settings pane resolves it through ClaudePlugin.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.aiagentclaude.backend.ClaudeBackend { + public ; +} + +# Keep the settings pane: it is named to the host as a string by +# ClaudeBackend.getSettingsFragmentClassName() and instantiated reflectively. +-keep public class com.itsaky.androidide.plugins.aiagentclaude.settings.ClaudeSettingsFragment { + public ; +} + +# Keep plugin-api interfaces +-keep interface com.itsaky.androidide.plugins.** { *; } diff --git a/plugins/AI-Agent-Claude/settings.gradle.kts b/plugins/AI-Agent-Claude/settings.gradle.kts new file mode 100644 index 00000000..43f3d863 --- /dev/null +++ b/plugins/AI-Agent-Claude/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:9.3.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "ai-agent-claude" diff --git a/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml b/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml new file mode 100644 index 00000000..d769b146 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/AI-Agent-Claude/src/main/assets/docs/index.html b/plugins/AI-Agent-Claude/src/main/assets/docs/index.html new file mode 100644 index 00000000..8b857db0 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/assets/docs/index.html @@ -0,0 +1,155 @@ + + + + + +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. If a key is already saved, only + Remove stays behind: the key is kept for the servers that need it and + is never sent to a server it was not saved for.
  • +
  • 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/plugins/AI-Agent-Claude/src/main/assets/icon_day.png b/plugins/AI-Agent-Claude/src/main/assets/icon_day.png new file mode 100644 index 0000000000000000000000000000000000000000..029e044efac51d3cfd5d3182d7bbc45ac4f868b8 GIT binary patch literal 11404 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 dimensions). + * + * Observed rather than tabulated: a compatible server can serve any model under any name, and + * `text-embedding-3-small` and `text-embedding-ada-002` are both 1536-d, so a hardcoded table + * would be both incomplete and unable to tell those two apart. Keyed by model so switching the + * setting cannot report the previous model's width. + */ + @Volatile + private var observedDimensions: Pair? = 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" + + /** + * Default embedding model. Separate from [DEFAULT_MODEL] because the two pickers offer + * disjoint halves of the same catalog — see [ModelCatalogFilter]. + */ + const val DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" + + /** Chat endpoint, appended to the configured base URL. */ + private const val CHAT_COMPLETIONS_PATH = "/chat/completions" + + /** Embeddings endpoint, appended to the configured base URL. */ + private const val EMBEDDINGS_PATH = "/embeddings" + + /** 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 { + ClaudePreferences.of(context) + } catch (e: Exception) { + context.logger.error("ClaudeBackend: 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(ClaudePreferences.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(ClaudePreferences.KEY_MODEL, DEFAULT_MODEL) + ?.trim()?.takeIf { it.isNotEmpty() } + ?: DEFAULT_MODEL + + /** + * The embedding model this server is configured to use. + * + * Read at call time, like the chat model: a consumer holds this backend for the life of the + * IDE, so a cached value would keep embedding with a model the user has already replaced. + */ + override fun getEmbeddingModelId(): String = + openAiPrefs()?.getString(ClaudePreferences.KEY_EMBEDDING_MODEL, DEFAULT_EMBEDDING_MODEL) + ?.trim()?.takeIf { it.isNotEmpty() } + ?: DEFAULT_EMBEDDING_MODEL + + /** + * Vector length this server last produced for the configured embedding model. + * + * @return the observed width, or 0 before the first successful response — the honest answer, + * since only the server knows what a given id produces. Consumers that record provenance + * should take the width from the vectors themselves. + */ + override fun getEmbeddingDimensions(): Int { + val (model, dimensions) = observedDimensions ?: return 0 + return if (model == getEmbeddingModelId()) dimensions else 0 + } + + /** + * Embeds [texts] in request order, splitting into as many requests as the API's limits need. + * + * @param texts the batch to embed; an empty list completes with an empty list and no request + * @return the vectors, one per input and in the same order; completed exceptionally with the + * formatted failure when any request fails, so a caller indexing a project can abort rather + * than store a partial space + */ + override fun embed(texts: List): CompletableFuture> { + val future = CompletableFuture>() + if (texts.isEmpty()) { + future.complete(emptyList()) + return future + } + // 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 { + val keyStamp = storedKeyStamp() + try { + future.complete(embedBatches(texts)) + } catch (e: CancellationException) { + future.cancel(true) + throw e + } catch (e: Exception) { + context.logger.error("ClaudeBackend: embedding ${texts.size} texts failed", e) + // The formatted sentence, not the raw body: this message reaches the user through + // whichever consumer asked, exactly as a refused chat turn's does. + future.completeExceptionally(IOException(formatErrorMessage(e, keyStamp), e)) + } + } + future.cancelJobOnCancel(job) + + return future + } + + /** + * Issues one request per batch and concatenates the answers. + * + * Sequential rather than parallel: the batches of one index build are the same rate-limit + * bucket, so firing them at once buys a 429 instead of throughput. + * + * @param texts the whole batch, in the caller's order + * @return the vectors, in the caller's order + */ + private suspend fun embedBatches(texts: List): List { + val model = getEmbeddingModelId() + val vectors = ArrayList(texts.size) + for (batch in ClaudeEmbeddingProtocol.batches(texts)) { + coroutineContext.ensureActive() + val response = http.post( + url = getBaseUrl() + EMBEDDINGS_PATH, + // Blank is legitimate, unlike Gemini: a local server takes no key, as chat too. + apiKey = readApiKeyOrBlank(), + body = ClaudeEmbeddingProtocol.body(model, batch), + tag = NetworkTags.EMBEDDING, + ) { reader -> JSONObject(reader.readText()) } + vectors.addAll(ClaudeEmbeddingProtocol.vectors(response, batch.size)) + } + vectors.firstOrNull()?.let { observedDimensions = model to it.size } + context.logger.info( + "ClaudeBackend: embedded ${texts.size} texts with $model " + + "(${vectors.firstOrNull()?.size ?: 0} dimensions)" + ) + return vectors + } + + /** + * 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.claude_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("ClaudeBackend: could not resolve label $resId", e) + fallback + } + + /** + * Written for a large cloud model; see [ClaudeSystemPrompt] for why the wording belongs here + * rather than with the caller. + */ + override fun getSystemPrompt(request: SystemPromptRequest): String = + ClaudeSystemPrompt.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.aiagentclaude.settings.ClaudeSettingsFragment" + + /** + * 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("ClaudeBackend.isAvailable() - custom server configured: $baseUrl") + return true + } + // Origin-checked, or the backend would report itself ready on a key it will not send. + val apiKey = readApiKeyOrBlank() + context.logger.debug("ClaudeBackend.isAvailable() - API key configured: ${apiKey.isNotBlank()}") + return apiKey.isNotBlank() + } + + override fun generate(prompt: String, config: LlmConfig): CompletableFuture { + val future = CompletableFuture() + + val job = scope.launch { + val keyStamp = storedKeyStamp() + try { + val startTime = System.currentTimeMillis() + context.logger.info("ClaudeBackend: Generating response for prompt (${prompt.length} chars)") + + val messages = ClaudeRequestBuilder.messages(emptyList(), prompt, config.systemPrompt) + val text = requestText(messages, config) + + if (text.isBlank()) { + future.complete(LlmResponse.failure(failureMessages.of(ClaudeFailure.Failed(null)))) + } else { + val tokenCount = text.split("\\s+".toRegex()).size // Approximate token count + context.logger.info("ClaudeBackend: 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("ClaudeBackend: Error generating response", e) + future.complete(LlmResponse.failure(formatErrorMessage(e, keyStamp))) + } + } + currentJob = job + future.cancelJobOnCancel(job) + + return future + } + + override fun generateStreaming( + prompt: String, + config: LlmConfig, + callback: StreamCallback + ) { + streamMessages( + ClaudeRequestBuilder.messages(emptyList(), prompt, config.systemPrompt), + config, + emptyList(), + callback.asToolCallback() + ) + } + + override fun generateWithHistory( + history: List, + prompt: String, + config: LlmConfig + ): CompletableFuture { + context.logger.info("ClaudeBackend.generateWithHistory() called with ${history.size} messages") + + val future = CompletableFuture() + + val job = scope.launch { + val keyStamp = storedKeyStamp() + try { + val startTime = System.currentTimeMillis() + + val messages = ClaudeRequestBuilder.messages(history, prompt, config.systemPrompt) + val text = requestText(messages, config) + + if (text.isBlank()) { + future.complete(LlmResponse.failure(failureMessages.of(ClaudeFailure.Failed(null)))) + } else { + val tokenCount = text.split("\\s+".toRegex()).size + context.logger.info("ClaudeBackend: 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("ClaudeBackend: Error generating with history", e) + future.complete(LlmResponse.failure(formatErrorMessage(e, keyStamp))) + } + } + currentJob = job + future.cancelJobOnCancel(job) + + 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( + ClaudeRequestBuilder.messages(history, prompt, config.systemPrompt), + config, + emptyList(), + callback.asToolCallback() + ) + } + + /** + * Streams a turn with [tools] declared to the server, reporting each `tool_calls` entry through + * [ToolStreamCallback.onToolCall]. + * + * This is the path the agent takes. Declaring the tools is what stops the model writing a call + * as prose the caller has to parse back: the arguments arrive already structured, so a file + * whose contents contain quotes or newlines can no longer break the call carrying it + * (ADFA-5410). + * + * @param prompt the current user turn + * @param history the conversation so far, oldest first + * @param config sampling settings; its system prompt becomes the leading `system` turn + * @param tools the tools to declare; an empty list streams plain text + * @param callback receives tokens, tool calls, completion, and errors + */ + override fun generateStreamingWithTools( + prompt: String, + history: List, + config: LlmConfig, + tools: List, + callback: ToolStreamCallback + ) { + streamMessages( + ClaudeRequestBuilder.messages(history, prompt, config.systemPrompt), + config, + tools, + callback + ) + } + + /** + * Adapts a plain stream callback to the tool-aware one [streamMessages] takes. + * + * @return a [ToolStreamCallback] that forwards every event and reports no tool calls + */ + private fun StreamCallback.asToolCallback(): ToolStreamCallback = object : ToolStreamCallback { + override fun onToken(token: String) = this@asToolCallback.onToken(token) + override fun onToolCall(request: ToolCallRequest) = Unit + override fun onComplete(response: LlmResponse) = this@asToolCallback.onComplete(response) + override fun onError(error: String) = this@asToolCallback.onError(error) + } + + /** + * 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 tools the tools to declare, or empty to stream plain text + * @param callback receives tokens, tool calls, completion, and errors + */ + private fun streamMessages( + messages: JSONArray, + config: LlmConfig, + tools: List, + callback: ToolStreamCallback + ) { + currentJob = scope.launch { + val keyStamp = storedKeyStamp() + try { + val startTime = System.currentTimeMillis() + + val fullText = StringBuilder() + var chunkCount = 0 + var outcome = StreamOutcome() + var calls = emptyList() + withToolRetry(tools) { declared -> + // 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 accumulator = ClaudeToolProtocol.CallAccumulator() + val body = ClaudeRequestBuilder.body( + messages, + getModelName(), + stream = true, + config = config, + tuning = tuning, + tools = declared, + ) + Log.i( + TAG, + "REQUEST | model=${getModelName()} turns=${messages.length()} " + + "tools=${declared.size} " + + declared.joinToString(",") { it.name } + ) + outcome = streamOnce(body, accumulator) { chunk -> + chunkCount++ + fullText.append(chunk) + callback.onToken(chunk) + } + calls = accumulator.requests() + outcome.droppedCalls = accumulator.droppedCalls + } + } + + val finalText = fullText.toString() + Log.i( + TAG, + "STREAM | chars=${finalText.length} chunks=$chunkCount calls=${calls.size} " + + "dropped=${outcome.droppedCalls} finish=${outcome.finishReason}" + ) + // Reported after the stream, so a call the retry replaced never reaches the caller. + for (call in calls) { + Log.i( + TAG, + "FUNCTION_CALL | tool=${call.name} " + + "args=${call.args.orEmpty().keys.joinToString(",")}" + ) + callback.onToolCall(call) + } + + // A turn that called a tool and said nothing is the normal agent turn, so only a + // reply with neither text nor a call is empty. + if (calls.isEmpty() && 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( + "ClaudeBackend: stream produced no reply text " + + "(skipped=${outcome.skippedChunks}, " + + "reasoningChars=${outcome.reasoningChars}, " + + "droppedCalls=${outcome.droppedCalls}, " + + "finishReason=${outcome.finishReason})" + ) + callback.onError(failureMessages.of(emptyReplyFailure(outcome))) + } else { + val tokenCount = finalText.split("\\s+".toRegex()).size + context.logger.info("ClaudeBackend: 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() + Log.e(TAG, "STREAM | failed: ${e.message}", e) + context.logger.error("ClaudeBackend: Error in streaming", e) + callback.onError(formatErrorMessage(e, keyStamp)) + } + } + } + + /** + * 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 + * @param droppedCalls tool calls whose arguments never parsed, i.e. arrived half-written + */ + private data class StreamOutcome( + var skippedChunks: Int = 0, + var reasoningChars: Int = 0, + var finishReason: String? = null, + var droppedCalls: Int = 0, + ) + + /** + * 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. + * + * @param accumulator collects the native tool-call fragments the stream carries + * @return what else the stream carried, for diagnosing an empty reply + */ + private suspend fun streamOnce( + body: JSONObject, + accumulator: ClaudeToolProtocol.CallAccumulator, + 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 { + postChat( + 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) + + is SseChunk.Event.ToolCalls -> { + accumulator.accept(event.deltas) + // Prose the same chunk carried; usually empty, never dropped. + if (event.text.isNotEmpty()) 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( + "ClaudeBackend: 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): ClaudeFailure = when { + outcome.reasoningChars > 0 -> ClaudeFailure.ReasoningOnly + outcome.finishReason == "length" -> ClaudeFailure.TruncatedBeforeReply + // Arguments that stop mid-JSON are a cut-off reply, whatever the server said stopped it. + outcome.droppedCalls > 0 -> ClaudeFailure.TruncatedBeforeReply + else -> ClaudeFailure.EmptyReply(outcome.skippedChunks) + } + + /** + * Run [attempt] with [tools] declared and, if this server refuses a tool declaration, run it + * once more with none. + * + * The refusal is remembered per server so only the first turn pays for it. What it costs is + * real: the system prompt for this run was built for native calling, so it teaches no envelope + * and the model has no other way to reach a tool — the turn answers in prose. Servers that + * take `tools` are the overwhelming majority, and this keeps the rest chatting rather than + * failing outright. + * + * @param tools the tools this turn wants declared; an empty list skips straight through + * @param attempt the request to make, given the tools to actually declare + */ + private suspend fun withToolRetry( + tools: List, + attempt: suspend (List) -> Unit + ) { + val baseUrl = getBaseUrl() + if (tools.isEmpty() || toolsRejectedBy == baseUrl) { + attempt(emptyList()) + return + } + try { + attempt(tools) + } catch (e: CancellationException) { + throw e + } catch (e: ClaudeHttpException) { + if (!UnsupportedTools.rejectedIn(e.statusCode, e.body)) throw e + toolsRejectedBy = baseUrl + Log.w(TAG, "REQUEST | server refused a tool declaration; retrying with none") + context.logger.warn( + "ClaudeBackend: $baseUrl does not accept tool declarations; " + + "the agent cannot call tools on this server" + ) + // Said out loud, not only logged: from here the agent answers but never touches the + // project, which reads as the tools being broken. Once per server, since the flag + // above short-circuits every later turn. + notifyToolsUnsupported(baseUrl) + attempt(emptyList()) + } + } + + /** + * Tells the user this server cannot call tools, as a Toast: the run continues, so there is no + * error message to carry it, and the chat's own turn is an ordinary prose answer. + * + * @param baseUrl the server that refused, named in the message. + */ + private fun notifyToolsUnsupported(baseUrl: String) { + val appContext = context.androidContext.applicationContext + val message = appContext.getString(R.string.claude_error_tools_unsupported, baseUrl) + Handler(Looper.getMainLooper()).post { + Toast.makeText(appContext, message, Toast.LENGTH_LONG).show() + } + } + + /** + * 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: ClaudeHttpException) { + if (e.statusCode != 400) throw e + val param = UnsupportedParameter.nameIn(e.body) ?: throw e + val adjusted = tuning.without(param) ?: throw e + context.logger.info( + "ClaudeBackend: 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 = ClaudeRequestBuilder.body( + messages, getModelName(), stream = false, config = config, tuning = tuning + ) + text = postChat(body) { reader -> extractText(JSONObject(reader.readText())) } + } + return text + } + + /** + * POST [body] to the configured server's chat endpoint with the stored credential. + * + * Every generation goes through here, streaming or not, which is why the recorded refusal is + * cleared here — on the status line, since a 2xx is the server accepting the key whether or not + * the body that follows is read to the end, or cancelled, or dropped mid-stream. + * + * @param sse true to ask for the server-sent-events stream + * @param onConnected receives the live connection, so the caller can disconnect it on cancel + * @return whatever [readResponse] produced + */ + private fun postChat( + body: JSONObject, + sse: Boolean = false, + onConnected: (HttpURLConnection) -> Unit = {}, + readResponse: (BufferedReader) -> T, + ): T = http.post( + url = getBaseUrl() + CHAT_COMPLETIONS_PATH, + apiKey = readApiKeyOrBlank(), + body = body, + sse = sse, + onConnected = onConnected, + onAccepted = { credentialFailures.clear() }, + readResponse = readResponse, + ) + + /** + * List what the configured server offers, split into the models each picker may show. + * + * Completes with an empty catalog when the server answered with none, and exceptionally on a + * network/API failure — an HTTP one as an [ClaudeHttpException], so the caller can tell a + * refused key from an unreachable server. + */ + internal fun listCatalog(): CompletableFuture = listCatalog(readApiKeyOrBlank(), getBaseUrl()) + + /** + * List what a caller-supplied credential and server offer. + * + * Lets the settings pane check a just-typed key or URL *before* either is persisted; the no-arg + * [listCatalog] reads what is on disk. Nothing here touches the stored key or its cache. + * + * One request answers both pickers: a second round trip would let the chat and embedding lists + * disagree about what the server offers. + * + * @param apiKey the candidate key, or blank for a server that needs none; never logged + * @param baseUrl the candidate server, normalized by the caller + */ + internal fun listCatalog(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 catalog = fetchAvailableModels(apiKey.trim(), baseUrl) + context.logger.info( + "ClaudeBackend: $baseUrl offers ${catalog.chat.size} chat and " + + "${catalog.embedding.size} embedding models" + ) + future.complete(catalog) + } catch (e: CancellationException) { + future.cancel(true) + throw e + } catch (e: Exception) { + context.logger.warn("ClaudeBackend: model listing failed: ${e.message}") + future.completeExceptionally(e) + } + } + future.cancelJobOnCancel(job) + + return future + } + + /** + * When the stored key was saved, or 0 when none is stored. + * + * Read into a local at the top of each request and carried to that request's error handler: a + * field on the backend is overwritten by any other key read before the refusal lands. + */ + private fun storedKeyStamp(): Long = + openAiPrefs()?.getLong(ClaudePreferences.KEY_API_KEY_TIMESTAMP, 0L) ?: 0L + + /** + * The saved key, but only for the server it was saved for. + * + * A key entered for OpenAI must not travel to whatever server the URL is pointed at next: a + * loopback or private-range server is reached over plain `http`, so the bearer token would + * cross the local network in the clear. A key stored before the origin was recorded is still + * sent — it cannot be shown to belong elsewhere, and dropping it would break an upgrade. + */ + private fun readApiKeyOrBlank(): String { + val savedFor = openAiPrefs()?.getString(ClaudePreferences.KEY_API_KEY_URL, null) + if (savedFor != null && !BaseUrlPolicy.sameOrigin(savedFor, getBaseUrl())) { + context.logger.debug("ClaudeBackend: saved key belongs to another server; sending none") + return "" + } + return keyCache.read().orEmpty() + } + + /** Fetch and split `GET {baseUrl}/models`. Runs on the caller's (IO) coroutine. */ + private fun fetchAvailableModels(apiKey: String, baseUrl: String): ModelCatalog { + val body = http.get(baseUrl + MODELS_PATH, apiKey) + val data = JSONObject(body).optJSONArray("data") ?: return ModelCatalog.EMPTY + val ids = (0 until data.length()).mapNotNull { index -> + data.optJSONObject(index)?.optString("id")?.takeIf { it.isNotBlank() } + } + return ModelCatalog( + chat = ModelCatalogFilter.chatModels(ids), + embedding = ModelCatalogFilter.embeddingModels(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. + * + * [ClaudeErrorFormatter] 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. + * + * @param keyStamp when the key this request read was saved, so a refusal landing after a later + * save or clear is not reported against a credential that was never tried + */ + private fun formatErrorMessage(e: Exception, keyStamp: Long): String { + val baseUrl = getBaseUrl() + val failure = ClaudeErrorFormatter.classify( + error = e, + modelName = getModelName(), + hasApiKey = readApiKeyOrBlank().isNotBlank(), + isClaudeHost = BaseUrlPolicy.requiresApiKey(baseUrl), + ) + val message = failureMessages.of(failure) + // Only a credential failure is recorded: any other reason says nothing about the key, and + // filing it as one would send the user off to replace a key that works. + if (failure.isCredentialProblem) credentialFailures.record(failure, keyStamp) + return message + } +} + +/** + * 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeEmbeddingProtocol.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeEmbeddingProtocol.kt new file mode 100644 index 00000000..73a290b3 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeEmbeddingProtocol.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +import java.io.IOException +import org.json.JSONArray +import org.json.JSONObject + +/** + * The `POST /v1/embeddings` wire format, as pure functions. + * + * Kept apart from [ClaudeBackend] so the one rule that is easy to get silently wrong — the answer + * is ordered by each element's own `index`, not by arrival — is unit-testable without a server. + */ +internal object ClaudeEmbeddingProtocol { + + /** + * Inputs per request. The API takes an array but bills and rate-limits by token, and a batch + * large enough to exceed the model's context window is rejected whole, so the batch the caller + * asked for is split rather than gambled on. + */ + const val MAX_INPUTS_PER_REQUEST = 96 + + /** + * Characters per request, as the cheap stand-in for a token budget. + * + * Roughly four characters to a token, kept well under the 8 192-token window the current + * embedding models share so a batch of long code chunks still fits. + */ + const val MAX_CHARS_PER_REQUEST = 24_000 + + /** + * Splits [texts] into requests that respect [MAX_INPUTS_PER_REQUEST] and + * [MAX_CHARS_PER_REQUEST], preserving order. + * + * A single text over the character budget is sent alone rather than dropped: only the server + * knows its real token count, and refusing it here would silently lose a chunk from the index. + * + * @param texts the batch as the caller asked for it + * @return the requests to issue, in order; empty when [texts] is empty + */ + fun batches(texts: List): List> { + val batches = mutableListOf>() + var current = mutableListOf() + var currentChars = 0 + + for (text in texts) { + val wouldOverflow = current.isNotEmpty() && + (current.size >= MAX_INPUTS_PER_REQUEST || + currentChars + text.length > MAX_CHARS_PER_REQUEST) + if (wouldOverflow) { + batches.add(current) + current = mutableListOf() + currentChars = 0 + } + current.add(text) + currentChars += text.length + } + + if (current.isNotEmpty()) batches.add(current) + return batches + } + + /** + * Builds the request body for one batch. + * + * @param model the embedding model id to request + * @param texts the batch to embed, in the caller's order + * @return the request JSON + */ + fun body(model: String, texts: List): JSONObject = JSONObject() + .put("model", model) + .put("input", JSONArray().apply { texts.forEach(::put) }) + + /** + * Reads a response back into the caller's order. + * + * The API documents `data[].index` precisely because the array may not arrive in request + * order, and a batch reordered by accident attaches every vector to the wrong chunk — an + * index that is silently wrong rather than visibly broken. + * + * @param response the parsed response body + * @param expected how many vectors the request asked for + * @return the vectors, in request order + * @throws IOException when the answer is short, long, or carries an index it cannot place + */ + fun vectors(response: JSONObject, expected: Int): List { + val data = response.optJSONArray("data") + ?: throw IOException("Embeddings response carried no data array") + if (data.length() != expected) { + throw IOException( + "Embeddings response carried ${data.length()} vectors for $expected inputs" + ) + } + + val ordered = arrayOfNulls(expected) + for (position in 0 until data.length()) { + val element = data.optJSONObject(position) + ?: throw IOException("Embeddings response element $position is not an object") + // Defaulting to the arrival position keeps a server that omits `index` working; only a + // value that is present and out of range is a protocol error. + val index = element.optInt("index", position) + if (index !in 0 until expected || ordered[index] != null) { + throw IOException("Embeddings response carried an unusable index: $index") + } + ordered[index] = floats(element.optJSONArray("embedding"), index) + } + // Every slot is filled: `expected` distinct in-range indices over `expected` slots. + return ordered.map { requireNotNull(it) } + } + + /** + * Reads one `embedding` array. + * + * @param values the raw array, or null when the element carried none + * @param index the element's index, named in the failure so a bad row is identifiable + * @throws IOException when the vector is missing or empty + */ + private fun floats(values: JSONArray?, index: Int): FloatArray { + if (values == null || values.length() == 0) { + throw IOException("Embeddings response element $index carried no vector") + } + return FloatArray(values.length()) { position -> values.getDouble(position).toFloat() } + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeHttpClient.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeHttpClient.kt new file mode 100644 index 00000000..27aee77d --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeHttpClient.kt @@ -0,0 +1,117 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeHttpException +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. + * + * Both entry points tag their sockets ([NetworkTags]): the host's debug builds install + * `StrictMode.VmPolicy.detectAll()`, and an untagged socket trips `detectUntaggedSockets()` from + * inside plugin code. + * + * @param connectTimeoutMs how long to wait for the connection itself + * @param readTimeoutMs how long a generation may take to answer + */ +internal class ClaudeHttpClient( + 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 tag the [NetworkTags] value to tag this request's socket with + * @param onConnected receives the live connection, so a caller can disconnect it on cancellation + * @param onAccepted called once the status line says 2xx, before a byte of the body is read + * @return whatever [readResponse] produced + * @throws ClaudeHttpException on a non-2xx answer, carrying the server's error body + */ + fun post( + url: String, + apiKey: String, + body: JSONObject, + sse: Boolean = false, + tag: Int = NetworkTags.INFERENCE, + onConnected: (HttpURLConnection) -> Unit = {}, + onAccepted: () -> Unit = {}, + readResponse: (BufferedReader) -> T, + ): T = withTrafficTag(tag) { + 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) + try { + conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) } + conn.failIfNotOk() + onAccepted() + conn.inputStream.bufferedReader().use(readResponse) + } finally { + conn.disconnect() + } + } + + /** + * GET [url] and return its response body, over a socket tagged [NetworkTags.CATALOG]. + * + * @param apiKey bearer token, or blank for a server that needs none + * @throws ClaudeHttpException on a non-2xx answer, carrying the server's error body + */ + fun get(url: String, apiKey: String): String = withTrafficTag(NetworkTags.CATALOG) { + val conn = open(url, "GET", apiKey) + 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 ClaudeHttpException(code, body) + } + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilder.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilder.kt new file mode 100644 index 00000000..f7d9733f --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilder.kt @@ -0,0 +1,103 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +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 ClaudeRequestBuilder { + + 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 + // A `tool` role is only legal after an assistant turn carrying the matching + // `tool_calls`, which ChatMessage gives the assistant turn no way to hold. + 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 + * @param tools the tools to declare; omitted from the body when empty + * @return the request JSON + */ + fun body( + messages: JSONArray, + model: String, + stream: Boolean, + config: LlmConfig, + tuning: RequestTuning, + tools: List = emptyList(), + ): JSONObject { + val body = JSONObject() + .put("model", model) + .put("messages", messages) + .put("stream", stream) + + // Declared, not described in the prompt: the arguments then arrive already structured, so + // a file whose contents carry quotes or newlines can no longer break the call (ADFA-5410). + if (tools.isNotEmpty()) { + body.put("tools", ClaudeToolProtocol.toolsArray(tools)) + } + + 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocol.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocol.kt new file mode 100644 index 00000000..dd498105 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocol.kt @@ -0,0 +1,212 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import org.json.JSONArray +import org.json.JSONObject + +/** + * This backend's half of the native function-calling protocol: `tools[]` out, `tool_calls` in. + * + * Pure and free of Android types, so the shapes that decide whether a tool call runs at all are + * unit-testable without a device or a network — see [ClaudeSystemPrompt] for the same reasoning. + */ +internal object ClaudeToolProtocol { + + /** + * Nesting a declared schema may carry. A contributed (MCP) schema is provider-supplied text, + * and a pathologically deep one would otherwise recurse until the host process dies. + */ + private const val MAX_SCHEMA_DEPTH = 12 + + /** + * One `tool_calls` fragment as it arrives on the stream. + * + * A call is spread across as many chunks as its arguments need, so no single fragment is a + * call; [CallAccumulator] joins them. + * + * @property index the call's position in the turn, which is what fragments are joined on. + * @property id the provider's call id, present on the first fragment only. + * @property name the tool's name, likewise present once. + * @property arguments this fragment's slice of the arguments JSON, possibly a partial token. + */ + data class ToolCallDelta( + val index: Int, + val id: String?, + val name: String?, + val arguments: String, + ) + + /** + * The `tools[]` array declaring [tools] to the server. + * + * @param tools the tools to declare. + * @return one `{"type":"function","function":{…}}` entry per tool. + */ + fun toolsArray(tools: List): JSONArray { + val declarations = JSONArray() + for (tool in tools) { + val function = JSONObject() + .put("name", tool.name) + .put("description", tool.description.orEmpty()) + .put("parameters", parametersJson(tool.parametersSchema)) + declarations.put(JSONObject().put("type", "function").put("function", function)) + } + return declarations + } + + /** + * The `parameters` value for a tool. + * + * An empty schema becomes a bare object rather than being omitted: omitting `parameters` + * declares a tool that takes none, and the model would then call it with nothing. + * + * @param schema the tool's JSON Schema, empty when it publishes none. + * @return the schema to declare. + */ + fun parametersJson(schema: Map?): JSONObject { + if (schema.isNullOrEmpty()) return JSONObject().put("type", "object") + return schemaJson(schema, MAX_SCHEMA_DEPTH) + } + + /** + * Converts a JSON Schema to JSON. + * + * Passed through keyword for keyword, unlike the Gemini transport's whitelist: this protocol + * takes plain JSON Schema, which is the dialect a contributed tool already arrives in. + * + * @param schema the tool's JSON Schema. + * @param depth how much further nesting to render; a deeper subtree is dropped. + * @return the equivalent JSON. + */ + private fun schemaJson(schema: Map<*, *>, depth: Int): JSONObject { + val json = JSONObject() + if (depth <= 0) return json + for ((key, value) in schema) { + val name = key as? String ?: continue + json.put(name, jsonValue(value, depth)) + } + return json + } + + /** One schema value: a nested schema, a list of them, or a scalar as it stands. */ + private fun jsonValue(value: Any?, depth: Int): Any = when (value) { + null -> JSONObject.NULL + is Map<*, *> -> schemaJson(value, depth - 1) + is Collection<*> -> JSONArray().apply { value.forEach { put(jsonValue(it, depth)) } } + else -> value + } + + /** + * The `tool_calls` fragments carried by one streamed `delta` (or one-shot `message`). + * + * @param delta the chunk's `delta` or `message` object, or null when it has neither. + * @return the fragments, empty when the chunk carries no call. + */ + fun toolCallDeltas(delta: JSONObject?): List { + val calls = delta?.optJSONArray("tool_calls") ?: return emptyList() + val deltas = mutableListOf() + for (i in 0 until calls.length()) { + val call = calls.optJSONObject(i) ?: continue + val function = call.optJSONObject("function") + deltas += ToolCallDelta( + // Absent on servers that send a whole call per chunk; position in the array then. + index = if (call.has("index")) call.optInt("index") else i, + id = call.optString("id").takeIf { it.isNotBlank() }, + name = function?.optString("name")?.takeIf { it.isNotBlank() }, + arguments = function?.optString("arguments").orEmpty(), + ) + } + return deltas + } + + /** + * Joins streamed [ToolCallDelta] fragments back into whole calls. + * + * Not thread-safe: it belongs to the one reader loop consuming a single response body. + */ + class CallAccumulator { + + /** One call under construction, fed by every fragment carrying its index. */ + private class Entry(val id: String?, var name: String?) { + val arguments = StringBuilder() + } + + /** Every call this turn has begun, in arrival order. */ + private val entries = mutableListOf() + + /** The call each index is still receiving fragments for. */ + private val open = HashMap() + + /** + * Calls whose arguments never parsed, as of the last [requests] call. + * + * The diagnostic for a turn that asked for a tool and ran none: a cut-off stream leaves + * arguments half-written, which is a truncated reply rather than an empty one. + */ + var droppedCalls: Int = 0 + private set + + /** + * Folds one chunk's fragments in. + * @param deltas the fragments, in the order the chunk carried them. + */ + fun accept(deltas: List) { + for (delta in deltas) { + val existing = open[delta.index] + // A new id at a live index means a second call, not more of the first one. + val entry = if (existing == null || (delta.id != null && delta.id != existing.id)) { + Entry(delta.id, delta.name).also { entries += it; open[delta.index] = it } + } else { + existing.apply { if (name == null) name = delta.name } + } + entry.arguments.append(delta.arguments) + } + } + + /** + * The calls accumulated so far, in the order the stream began them. + * + * A call whose arguments will not parse is left out and counted in [droppedCalls] rather + * than reported with empty arguments, which would run the tool on nothing. + * + * @return the whole calls; empty when the turn carried none. + */ + fun requests(): List { + val requests = mutableListOf() + var dropped = 0 + for (entry in entries) { + val name = entry.name + if (name.isNullOrBlank()) { + dropped++ + continue + } + val args = argsOf(entry.arguments.toString()) + if (args == null) { + dropped++ + continue + } + // Paired by name when the server sent no id, never by an id it would not recognise. + requests += ToolCallRequest(entry.id ?: name, name, args) + } + droppedCalls = dropped + return requests + } + } + + /** + * Reads one call's `arguments` string. + * + * @param arguments the accumulated JSON; blank for a tool called with none. + * @return the arguments, or null when the JSON is incomplete or malformed. + */ + fun argsOf(arguments: String): Map? { + val text = arguments.trim() + if (text.isEmpty()) return emptyMap() + val json = runCatching { JSONObject(text) }.getOrNull() ?: return null + val args = mutableMapOf() + // Values stay as org.json types, as the Gemini transport also hands them over. + for (key in json.keys()) args[key] = json.get(key) + return args + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalog.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalog.kt new file mode 100644 index 00000000..3a68050d --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalog.kt @@ -0,0 +1,20 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +/** + * One server's catalog, split into the models each picker may offer. + * + * Carried as one value because both halves come from one `GET /v1/models`: fetching them + * separately would let the two pickers describe different snapshots of the same server. + * + * @param chat models the chat picker may offer + * @param embedding models the embedding picker may offer + */ +internal data class ModelCatalog( + val chat: List, + val embedding: List, +) { + companion object { + /** What a server that listed nothing offers. */ + val EMPTY = ModelCatalog(emptyList(), emptyList()) + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalogFilter.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalogFilter.kt new file mode 100644 index 00000000..77de299e --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ModelCatalogFilter.kt @@ -0,0 +1,82 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +/** + * Splits a raw `GET /v1/models` listing into the models each picker may offer. + * + * 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 over + * the id, and the two pickers are derived from one marker list on purpose: the embedding picker + * offers exactly what the chat picker hides, so the two cannot drift into offering the same model + * for both jobs or into hiding a model from both. + */ +internal object ModelCatalogFilter { + + /** + * Substrings that mark an embedding model. + * + * The embedding picker is an allowlist over these — a model it wrongly omits can still be + * typed in, and one it wrongly offers fails once with a clear server error. The families + * after the generic markers are the local-server ones whose ids say nothing about embedding: + * `bge-m3`, `all-minilm`, `gte-large` and `e5-base` would otherwise be missing from the + * embedding picker and offered in the chat one. + */ + private val EMBEDDING_MARKERS = + listOf("embed", "embedding", "bge-", "minilm", "gte-", "e5-") + + /** + * Substrings that mark a non-chat model, embedding markers included. + * + * Matched on the whole id, so vendor-prefixed OpenRouter ids are covered too. The chat picker + * is a denylist over these: an unknown id is kept, because a wrongly hidden model cannot be + * selected at all while a wrongly offered one merely fails once. + */ + private val NON_CHAT_MARKERS = EMBEDDING_MARKERS + listOf( + "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 down to the chat models. + * + * @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 = normalize(ids).filter(::isPlausibleChatModel) + + /** + * Filters and orders a raw model listing down to the embedding models. + * + * @param ids model ids exactly as the server returned them + * @return the plausible embedding models, de-duplicated and sorted for a stable picker + */ + fun embeddingModels(ids: List): List = + normalize(ids).filter(::isPlausibleEmbeddingModel) + + /** + * True when [id] could be a chat model. + * + * @param id one model id from the listing + */ + fun isPlausibleChatModel(id: String): Boolean = NON_CHAT_MARKERS.none { marker -> + id.lowercase().contains(marker) + } + + /** + * True when [id] could be an embedding model. + * + * @param id one model id from the listing + */ + fun isPlausibleEmbeddingModel(id: String): Boolean = EMBEDDING_MARKERS.any { marker -> + id.lowercase().contains(marker) + } + + /** Trims, drops blanks and duplicates, and sorts, so a picker's order is stable. */ + private fun normalize(ids: List): List = ids + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .sorted() +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTags.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTags.kt new file mode 100644 index 00000000..e7b69ce6 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTags.kt @@ -0,0 +1,38 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +import android.net.TrafficStats + +/** + * Thread stats tags for this plugin's sockets: ASCII bytes, so a raw `dumpsys netstats` dump stays + * readable. Keep-alive pooling makes the split approximate — a pooled socket keeps the tag it was + * born with, and inference and the catalog share a base URL. + */ +internal object NetworkTags { + + /** Chat completions, streaming and not — `"OAIN"`. */ + const val INFERENCE = 0x4F41494E + + /** The model catalog — `"OACT"`. */ + const val CATALOG = 0x4F414354 + + /** Embeddings — `"OAEM"`. */ + const val EMBEDDING = 0x4F41454D +} + +/** + * Run [block] with [tag] on this thread's sockets, restoring the previous tag in a `finally` so the + * tag never leaks onto these shared `Dispatchers.IO` threads and an enclosing tag survives. Not + * `inline` on purpose: the tag is thread-local, so [block] must not suspend. + * + * @param tag one of [NetworkTags] + * @return whatever [block] returned + */ +internal fun withTrafficTag(tag: Int, block: () -> T): T { + val previous = TrafficStats.getThreadStatsTag() + TrafficStats.setThreadStatsTag(tag) + return try { + block() + } finally { + TrafficStats.setThreadStatsTag(previous) + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/RequestTuning.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/RequestTuning.kt new file mode 100644 index 00000000..bc4161e4 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/RequestTuning.kt @@ -0,0 +1,169 @@ +package com.itsaky.androidide.plugins.aiagentclaude.backend + +import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeErrorFormatter + +/** + * 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 (!saysUnsupported(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? = + ClaudeErrorFormatter.errorObjectIn(body)?.optString("param") + ?.takeIf { it.isNotBlank() }?.lowercase() + +} + +/** + * Whether a server refused the request over the `tools` declaration itself. + * + * Its own detector rather than another [UnsupportedParameter] entry: dropping `tools` changes which + * protocol the run uses, so it is recovered from once per server instead of once per request. + */ +internal object UnsupportedTools { + + /** Statuses a compatible server uses to refuse a parameter outright. */ + private val REFUSAL_STATUSES = setOf(400, 404, 422) + + /** Request fields that only exist to carry tools, in either the modern or legacy spelling. */ + private val TOOL_FIELDS = listOf("tools", "tool_choice", "functions", "function_call") + + /** + * True when [body] says this server will not take a tool declaration. + * + * A rejected *schema* lands here too and degrades the same way, which is a turn that still + * answers rather than a lost one — see `ClaudeBackend.withToolRetry` for what that costs. + * + * @param statusCode the HTTP status the server answered with + * @param body the response body of that failure + * @return true to retry the request with no tools declared + */ + fun rejectedIn(statusCode: Int, body: String?): Boolean { + if (statusCode !in REFUSAL_STATUSES) return false + if (body.isNullOrBlank()) return false + val text = body.lowercase() + if (!saysUnsupported(text)) return false + return TOOL_FIELDS.any { text.contains(it) } + } +} + +/** + * True when an error body says a request field is not accepted, rather than that its value is bad. + * + * @param text the response body, lowercased + */ +private fun saysUnsupported(text: String): Boolean = listOf( + "unsupported", "not supported", "unrecognized", "unknown", "unexpected", + "is not permitted", "instead", "deprecated", "extra inputs", +).any { text.contains(it) } diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/SseChunk.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/SseChunk.kt new file mode 100644 index 00000000..b51c0087 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/SseChunk.kt @@ -0,0 +1,163 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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 + + /** + * Fragments of one or more native tool calls, for [ClaudeToolProtocol.CallAccumulator]. + * + * @property deltas the chunk's `tool_calls` fragments. + * @property text prose the same chunk carried, which a call riding along must not drop. + */ + data class ToolCalls( + val deltas: List, + 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() + val toolCalls = mutableListOf() + 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)) + toolCalls += ClaudeToolProtocol.toolCallDeltas(delta) + choice.optString("finish_reason").takeIf { it.isNotBlank() && it != "null" } + ?.let { finishReason = it } + } + + return when { + // Ahead of the text, which rides along: a call delta carries no `content`, so a chunk + // of them was `Ignored` before native calling and the turn came back empty. + toolCalls.isNotEmpty() -> Event.ToolCalls(toolCalls, content.toString()) + 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatter.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatter.kt new file mode 100644 index 00000000..14b6fee6 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatter.kt @@ -0,0 +1,271 @@ +package com.itsaky.androidide.plugins.aiagentclaude.errors + +import androidx.annotation.StringRes +import com.itsaky.androidide.plugins.aiagentclaude.R +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 ClaudeApiError( + /** 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 ClaudeFailure { + + /** The model is unknown to this server (HTTP 404, or `model_not_found`). */ + data class ModelUnavailable(val modelName: String) : ClaudeFailure + + /** Rate limit or spent quota (HTTP 429). The key itself is fine. */ + data object QuotaExceeded : ClaudeFailure + + /** The account has no credit left (HTTP 429 whose body names billing or credit). */ + data object BillingRequired : ClaudeFailure + + /** The credential was refused (HTTP 401, or `invalid_api_key`). */ + data object KeyRefused : ClaudeFailure + + /** The server needs a key and none was configured (HTTP 401 with nothing sent). */ + data object KeyMissing : ClaudeFailure + + /** The key is valid but not allowed to use this model or endpoint (HTTP 403). */ + data object KeyForbidden : ClaudeFailure + + /** HTTP 400 about the request rather than the credential. */ + data class RequestRejected(val reason: String?) : ClaudeFailure + + /** Server-side outage (HTTP 5xx). Says nothing about the key or the model. */ + data class ServiceUnavailable(val httpStatus: Int) : ClaudeFailure + + /** An HTTP status with no specific handling. */ + data class Unexpected(val httpStatus: Int, val reason: String?) : ClaudeFailure + + /** + * 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 : ClaudeFailure + + /** No response and the server was OpenAI itself, i.e. the device has no route out. */ + data object Unreachable : ClaudeFailure + + /** + * 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) : ClaudeFailure + + /** + * The model produced only thinking text and never got to an answer — almost always the token + * cap being consumed by reasoning. + */ + data object ReasoningOnly : ClaudeFailure + + /** The token cap cut the turn off before any reply text arrived (`finish_reason: length`). */ + data object TruncatedBeforeReply : ClaudeFailure + + /** Everything else, including failures that never reached the network. */ + data class Failed(val reason: String?) : ClaudeFailure +} + +/** + * A credential failure in the form the settings pane can be handed: a tag stable enough to persist, + * and the wording resolved wherever it is shown. + * + * The pane is handed one of these rather than a rendered sentence. A sentence recorded at the + * moment of failure is frozen in the locale it was produced in — change the device language and + * the banner reads half in each — and it outlives a later correction to the wording. + */ +internal enum class CredentialFailure(val tag: String, @get:StringRes val messageRes: Int) { + KeyRefused("key_refused", R.string.claude_error_key_refused), + KeyMissing("key_missing", R.string.claude_error_key_missing), + KeyForbidden("key_forbidden", R.string.claude_error_key_forbidden); + + companion object { + /** + * The credential failure [failure] is, or null when it is about something else. + * + * The settings pane reports only these: a 500, a spent quota or an unreachable server says + * nothing about the key, and reporting one as a credential problem would send the user off + * to replace a key that works. [ClaudeFailure.QuotaExceeded] and + * [ClaudeFailure.BillingRequired] are deliberately outside it — the key was accepted, the + * account simply has nothing left to spend. Listed exhaustively so a failure added later + * has to be classified here. + */ + fun of(failure: ClaudeFailure): CredentialFailure? = when (failure) { + ClaudeFailure.KeyRefused -> KeyRefused + ClaudeFailure.KeyMissing -> KeyMissing + ClaudeFailure.KeyForbidden -> KeyForbidden + is ClaudeFailure.ModelUnavailable, + ClaudeFailure.QuotaExceeded, + ClaudeFailure.BillingRequired, + is ClaudeFailure.RequestRejected, + is ClaudeFailure.ServiceUnavailable, + is ClaudeFailure.Unexpected, + ClaudeFailure.ServerNotRunning, + ClaudeFailure.Unreachable, + is ClaudeFailure.EmptyReply, + ClaudeFailure.ReasoningOnly, + ClaudeFailure.TruncatedBeforeReply, + is ClaudeFailure.Failed -> null + } + + /** The failure [tag] names, or null for a tag this release no longer knows. */ + fun ofTag(tag: String): CredentialFailure? = entries.firstOrNull { it.tag == tag } + } +} + +/** Whether this failure is about the credential rather than the request, the model or the network. */ +internal val ClaudeFailure.isCredentialProblem: Boolean + get() = CredentialFailure.of(this) != null + +/** + * Classifies an OpenAI-compatible failure so it can be reported as one translated sentence. + * + * The log keeps the full body; **no [ClaudeFailure] ever carries a JSON payload** — putting the raw + * error body in the chat transcript is the bug this class exists to prevent. + */ +object ClaudeErrorFormatter { + + /** + * Matches the status in an `OpenAI HTTP 404: {…}` message. A fallback: a status that arrived as + * an [ClaudeHttpException] 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?): ClaudeApiError { + val raw = rawMessage.orEmpty() + val error = errorObjectIn(raw) + + return ClaudeApiError( + 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 isClaudeHost whether the target was OpenAI itself, which changes the no-answer advice + */ + fun classify( + error: Throwable, + modelName: String, + hasApiKey: Boolean, + isClaudeHost: Boolean, + ): ClaudeFailure { + 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? ClaudeHttpException)?.statusCode ?: parsed.httpStatus + + return when { + status == 404 || parsed.apiCode == "model_not_found" -> + ClaudeFailure.ModelUnavailable(modelName) + + status == 429 && parsed.mentionsBilling() -> ClaudeFailure.BillingRequired + + status == 429 || parsed.apiCode == "rate_limit_exceeded" -> + ClaudeFailure.QuotaExceeded + + status == 401 && !hasApiKey -> ClaudeFailure.KeyMissing + + status == 401 || parsed.apiCode == "invalid_api_key" -> ClaudeFailure.KeyRefused + + status == 403 -> ClaudeFailure.KeyForbidden + + status == 400 -> ClaudeFailure.RequestRejected(safeReason(parsed, error)) + + status != null && status in 500..599 -> ClaudeFailure.ServiceUnavailable(status) + + status != null -> ClaudeFailure.Unexpected(status, safeReason(parsed, error)) + + // No status at all: the request never got an answer. + error is IOException -> + if (isClaudeHost) ClaudeFailure.Unreachable else ClaudeFailure.ServerNotRunning + + else -> ClaudeFailure.Failed(safeReason(parsed, error)) + } + } + + /** True when a 429 is about money rather than request rate. */ + private fun ClaudeApiError.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: ClaudeApiError, 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeFailureMessages.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeFailureMessages.kt new file mode 100644 index 00000000..dcaa5245 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeFailureMessages.kt @@ -0,0 +1,81 @@ +package com.itsaky.androidide.plugins.aiagentclaude.errors + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiagentclaude.R + +/** + * The wording for an [ClaudeFailure]. + * + * `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 ClaudeFailureMessages( + 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: ClaudeFailure): String = try { + val resources = context.androidContext + when (failure) { + is ClaudeFailure.ModelUnavailable -> + resources.getString(R.string.claude_error_model_unavailable, failure.modelName) + + ClaudeFailure.QuotaExceeded -> + resources.getString(R.string.claude_error_quota) + + ClaudeFailure.BillingRequired -> + resources.getString(R.string.claude_error_billing) + + // Through CredentialFailure, which is also what the settings pane resolves, so the + // transcript and the pane cannot describe the same refusal differently. + ClaudeFailure.KeyRefused -> + resources.getString(CredentialFailure.KeyRefused.messageRes) + + ClaudeFailure.KeyMissing -> + resources.getString(CredentialFailure.KeyMissing.messageRes) + + ClaudeFailure.KeyForbidden -> + resources.getString(CredentialFailure.KeyForbidden.messageRes) + + is ClaudeFailure.RequestRejected -> failure.reason?.let { + resources.getString(R.string.claude_error_request_rejected_reason, it) + } ?: resources.getString(R.string.claude_error_request_rejected) + + is ClaudeFailure.ServiceUnavailable -> + resources.getString(R.string.claude_error_service_unavailable, failure.httpStatus) + + is ClaudeFailure.Unexpected -> failure.reason?.let { + resources.getString(R.string.claude_error_unexpected_reason, failure.httpStatus, it) + } ?: resources.getString(R.string.claude_error_unexpected, failure.httpStatus) + + is ClaudeFailure.EmptyReply -> + resources.getString(R.string.claude_error_empty_reply) + + ClaudeFailure.ReasoningOnly -> + resources.getString(R.string.claude_error_reasoning_only) + + ClaudeFailure.TruncatedBeforeReply -> + resources.getString(R.string.claude_error_truncated) + + ClaudeFailure.ServerNotRunning -> + resources.getString(R.string.claude_error_server_not_running, baseUrl()) + + ClaudeFailure.Unreachable -> + resources.getString(R.string.claude_error_unreachable) + + is ClaudeFailure.Failed -> failure.reason?.let { + resources.getString(R.string.claude_error_failed_reason, it) + } ?: resources.getString(R.string.claude_error_failed) + } + } catch (e: Exception) { + context.logger.error("ClaudeFailureMessages: could not resolve a string for $failure", e) + "The request to the AI server failed." + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeHttpException.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeHttpException.kt new file mode 100644 index 00000000..2546c907 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeHttpException.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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 ClaudeHttpException( + val statusCode: Int, + val body: String, +) : IOException("OpenAI HTTP $statusCode: $body") diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureLog.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureLog.kt new file mode 100644 index 00000000..68e43201 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureLog.kt @@ -0,0 +1,63 @@ +package com.itsaky.androidide.plugins.aiagentclaude.errors + +import android.content.SharedPreferences +import com.itsaky.androidide.plugins.aiagentclaude.preferences.ClaudePreferences +import com.itsaky.androidide.plugins.aiagentclaude.settings.BaseUrlPolicy + +/** + * Why the last request was refused for credential reasons, kept for the settings pane to report. + * + * A refused request is otherwise only legible in the transcript, which the user has already left + * by the time they open the pane to fix the key — so the one screen that can fix it said nothing + * (ADFA-5491). Holds the classified failure, never the credential and never a rendered sentence. + * + * @param prefs this plugin's settings store; re-read on every call, and null before `initialize()` + */ +internal class CredentialFailureLog(private val prefs: () -> SharedPreferences?) { + + /** + * Records the credential problem [failure] describes, replacing any earlier one. A failure + * about anything else is not recorded — [CredentialFailure.of] is the same test the caller's + * [isCredentialProblem] applies, so this drops nothing a caller meant to record. + * + * @param keyStamp when the key the refused request carried was saved, taken where the request + * read that key rather than here: a refusal can land after the user has already saved a + * replacement, and this stamp is what keeps it from being reported against the new key + */ + fun record(failure: ClaudeFailure, keyStamp: Long) { + val credential = CredentialFailure.of(failure) ?: return + prefs()?.edit() + ?.putString(ClaudePreferences.KEY_CREDENTIAL_FAILURE, credential.tag) + ?.putLong(ClaudePreferences.KEY_CREDENTIAL_FAILURE_KEY_STAMP, keyStamp) + ?.apply() + } + + /** + * The recorded failure, or null unless it describes the credential that is on disk now, for + * the server in use now. Equal stamps mean nothing was saved or cleared between the refused + * request reading the key and now, so a refusal that landed after either is dropped. + */ + fun read(): CredentialFailure? { + val prefs = prefs() ?: return null + val tag = prefs.getString(ClaudePreferences.KEY_CREDENTIAL_FAILURE, null) + ?.takeIf { it.isNotBlank() } + ?: return null + val keyStamp = prefs.getLong(ClaudePreferences.KEY_CREDENTIAL_FAILURE_KEY_STAMP, 0L) + // Clear removes the key's timestamp too, so "not older than" would keep a keyless refusal. + if (keyStamp != prefs.getLong(ClaudePreferences.KEY_API_KEY_TIMESTAMP, 0L)) return null + // The key is origin-scoped, so off-origin the backend sent none and the refusal is not it. + val savedFor = prefs.getString(ClaudePreferences.KEY_API_KEY_URL, null) + val baseUrl = prefs.getString(ClaudePreferences.KEY_BASE_URL, null) + ?: BaseUrlPolicy.DEFAULT_BASE_URL + if (savedFor != null && !BaseUrlPolicy.sameOrigin(savedFor, baseUrl)) return null + return CredentialFailure.ofTag(tag) + } + + /** Forgets the reason: the credential changed, or a request has since gone through on it. */ + fun clear() { + prefs()?.edit() + ?.remove(ClaudePreferences.KEY_CREDENTIAL_FAILURE) + ?.remove(ClaudePreferences.KEY_CREDENTIAL_FAILURE_KEY_STAMP) + ?.apply() + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/logging/LogTags.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/logging/LogTags.kt new file mode 100644 index 00000000..cc951f82 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/logging/LogTags.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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 AiAgentClaude.*` is this plugin's log. + */ +internal const val LOG_PREFIX = "AiAgentClaude" diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt new file mode 100644 index 00000000..21696ad1 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt @@ -0,0 +1,345 @@ +package com.itsaky.androidide.plugins.aiagentclaude.plugin + +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeBackend +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 ClaudePlugin : IPlugin, DocumentationExtension { + + private lateinit var context: PluginContext + private var backend: ClaudeBackend? = 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.aiagentclaude" + + /** 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 ClaudeSettingsFragment). + const val TOOLTIP_TAG_SETTINGS_SERVER = "ai_claude_server" + const val TOOLTIP_TAG_SETTINGS_PRESET = "ai_claude_preset" + const val TOOLTIP_TAG_SETTINGS_KEY = "ai_claude_key" + const val TOOLTIP_TAG_SETTINGS_MODEL = "ai_claude_model" + const val TOOLTIP_TAG_SETTINGS_EMBEDDING_MODEL = "ai_claude_embedding_model" + const val TOOLTIP_TAG_SETTINGS_TEST = "ai_claude_test_connection" + const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_claude_get_key" + + @Volatile + private var pluginContext: PluginContext? = null + + @Volatile + private var activeBackend: ClaudeBackend? = 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(): ClaudeBackend? = 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("ClaudePlugin: Plugin initialized successfully") + true + } catch (e: Exception) { + context.logger.error("ClaudePlugin: Plugin initialization failed", e) + false + } + } + + override fun activate(): Boolean { + context.logger.info("ClaudePlugin: Activating plugin") + + return try { + // A half-failed activation can leave a backend behind; keep at most one live. + releaseBackend() + + val openAi = ClaudeBackend(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( + "ClaudePlugin: AI Core is not active yet; will register when it activates" + ) + } + + true + } catch (e: Exception) { + context.logger.error("ClaudePlugin: 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("ClaudePlugin: LlmInferenceService not available yet") + return false + } + + return try { + service.registerBackend(openAi) + registered = true + context.logger.info("ClaudePlugin: Registered '${openAi.getId()}' backend with AI Core") + true + } catch (e: Exception) { + context.logger.error("ClaudePlugin: 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("ClaudePlugin: Could not resolve LlmInferenceService: ${e.message}") + null + } + + override fun deactivate(): Boolean { + context.logger.info("ClaudePlugin: Deactivating plugin") + + return try { + context.removePluginLifecycleListener(aiCoreLifecycle) + + val openAi = backend + if (openAi != null && registered) { + resolveInferenceService()?.unregisterBackend(openAi.getId()) + registered = false + context.logger.info("ClaudePlugin: 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("ClaudePlugin: 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("ClaudePlugin: Disposing plugin") + + // deactivate() removes this too; a dispose without one would leave the host holding this. + runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } + + releaseBackend() + pluginContext = null + context.logger.info("ClaudePlugin: 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_EMBEDDING_MODEL, + summary = "Which model turns your code into vectors for semantic search. Never used for chat.", + detail = """ +

Semantic search compares meaning rather than words, which it + does by embedding every chunk of the project with this model. It + is a separate setting because embedding models and chat models are + different models: this list offers exactly what the Model + list above filters out.

+

Changing it changes the vector space, so the project is indexed + again from scratch. Vectors from two different models are not + comparable, and mixing them would quietly return worse results + rather than fail.

+

Free text always works, which matters for a local server — + nomic-embed-text on Ollama, for example.

+ """.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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/preferences/ClaudePreferences.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/preferences/ClaudePreferences.kt new file mode 100644 index 00000000..6811a8b7 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/preferences/ClaudePreferences.kt @@ -0,0 +1,108 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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 ClaudePreferences { + + /** This plugin's preferences file. Namespaced to this plugin by the host. */ + private const val FILE = "ClaudeSettings" + + /** Server base URL, e.g. `https://api.openai.com/v1`. Stored normalized. */ + const val KEY_BASE_URL = "claude_base_url" + + /** API key, stored as ciphertext only. Optional for a server that needs none. */ + const val KEY_API_KEY = "claude_api_key" + + const val KEY_API_KEY_TIMESTAMP = "claude_api_key_timestamp" + const val KEY_API_KEY_VERIFIED = "claude_api_key_verified" + + /** + * The base URL [KEY_API_KEY] was saved for. + * + * Stored alongside so the key is only ever sent to the server it was issued by: pointing the + * URL at a private-range address afterwards would otherwise put an OpenAI bearer token on the + * local network in the clear. + */ + const val KEY_API_KEY_URL = "claude_api_key_url" + + /** Model id to request, e.g. `gpt-5` or `qwen2.5-coder`. */ + const val KEY_MODEL = "claude_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 = "claude_model_url" + + /** Embedding model id to request, e.g. `text-embedding-3-small`. */ + const val KEY_EMBEDDING_MODEL = "claude_embedding_model" + + /** + * The base URL [KEY_EMBEDDING_MODEL] was chosen for. + * + * Stored alongside for the same reason as [KEY_MODEL_URL], and it matters more here: an + * embedding model carried over to another server produces vectors in a different space, which + * does not 404 — it silently ranks against the index built by the previous one. + */ + const val KEY_EMBEDDING_MODEL_URL = "claude_embedding_model_url" + + /** Set once the user has been warned about a cleartext URL, so the warning shows once. */ + const val KEY_CLEARTEXT_ACKNOWLEDGED = "claude_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 = "claude_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 = "claude_remembered_models_url" + + /** + * The embedding half of the last model list a server returned, remembered under its own key so + * reopening the settings pane offers both pickers without another request. Encoded by + * `RememberedModels`, and guarded by the same [KEY_REMEMBERED_MODELS_URL]: both halves come + * from one listing, so one origin describes them both. + */ + const val KEY_REMEMBERED_EMBEDDING_MODELS = "claude_remembered_embedding_models" + + /** + * Why the last request was refused for credential reasons, or absent. Diagnostics rather than a + * setting: written by the backend, and cleared when a new credential is saved or a request goes + * through on the stored one — not when the settings pane reads it, which happens on every + * rotation. + */ + const val KEY_CREDENTIAL_FAILURE = "claude_credential_failure" + + /** + * When the key that [KEY_CREDENTIAL_FAILURE] describes was saved, so a refusal that lands after + * a replacement was saved can be told from one about the key in use. + */ + const val KEY_CREDENTIAL_FAILURE_KEY_STAMP = "claude_credential_failure_key_stamp" + + /** + * 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt new file mode 100644 index 00000000..94b5d273 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt @@ -0,0 +1,110 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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 ClaudeSystemPrompt { + + /** + * 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" + + /** How to call a tool when the caller takes calls through the provider's own API. */ + private val NATIVE_CALL_FORMAT = """ + TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling + API. A call written into your reply text is NOT read by this system and will not run. + Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing. + """.trimIndent() + + /** + * Builds the prompt for [request]. + * + * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce + * replies nothing reads — and a null one means the caller reads calls off the provider's own + * function-calling API, so [NATIVE_CALL_FORMAT] replaces the envelope rather than joining it. + * + * @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. + - 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. Locate what you need with ONE search_project call — the IDE CONTEXT block above already names the source, layout and manifest paths + 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() + + // Null syntax means the caller reads calls off the function-calling API instead. Saying so + // is what stops the model writing one as text, where nothing would run it (ADFA-5410). + val syntax = request.toolCallSyntax ?: return listOf(head, NATIVE_CALL_FORMAT, workflow) + .joinToString("\n\n") + + 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. + Do NOT use your provider's native function-calling channel either; a structured tool call is not read by this system. + 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/ApiKeyCache.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/ApiKeyCache.kt new file mode 100644 index 00000000..1a178f5b --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/ApiKeyCache.kt @@ -0,0 +1,120 @@ +package com.itsaky.androidide.plugins.aiagentclaude.security + +import android.content.SharedPreferences +import android.os.Looper +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.security.KeystoreSecretStore +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 = when (val stored = secureApiKeyStore.readAndMigrate(prefs, prefKey)) { + is KeystoreSecretStore.Stored.Value -> stored.plain.trim().takeIf { it.isNotBlank() } + KeystoreSecretStore.Stored.Absent -> null + // Reported here rather than passed on as "no key": generation fails either way, but a + // lost Keystore entry needs the key entering again, and the log is all that says so. + KeystoreSecretStore.Stored.Unreadable -> { + logger.warn( + "ApiKeyCache: the saved API key cannot be decrypted on this device; " + + "it has to be entered again in settings" + ) + null + } + // Transient, so it returns without caching: the key is very likely intact, and caching + // this answer would freeze "no key" until the stored value itself changed. + KeystoreSecretStore.Stored.Unavailable -> { + logger.warn( + "ApiKeyCache: the keystore could not be reached to read the saved API key; " + + "retrying on the next read" + ) + return null + } + } + val raw = prefs?.getString(prefKey, null) + cached = raw?.let { it to plain } + return plain + } +} diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/SecureApiKeyStore.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/SecureApiKeyStore.kt new file mode 100644 index 00000000..c095f528 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/SecureApiKeyStore.kt @@ -0,0 +1,17 @@ +package com.itsaky.androidide.plugins.aiagentclaude.security + +import com.itsaky.androidide.plugins.security.KeystoreSecretStore + +/** Unique to this plugin and fixed across releases; see [KeystoreSecretStore] for why both matter. */ +private const val ALIAS = "cotg_ai_claude_key_v1" + +/** + * This plugin's binding of [KeystoreSecretStore]: its API key, encrypted under this plugin's own + * Keystore alias. + * + * The store is the IDE's, from plugin-api, and callers use it directly. A forwarding object per + * method would only be a second copy of its contract to keep in step — and one that had to pick a + * single answer for "absent" and "no longer decryptable", which callers here do not share. The + * thing this file owns is the alias. + */ +val secureApiKeyStore = KeystoreSecretStore(ALIAS) diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/BaseUrlPolicy.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/BaseUrlPolicy.kt new file mode 100644 index 00000000..7668622d --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/BaseUrlPolicy.kt @@ -0,0 +1,234 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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 [a] and [b] name the same server — same scheme, host and port. + * + * The path is ignored: `/v1` versus `/v1/` is the same endpoint, and a credential is issued by + * an origin rather than by a path. + * + * @return false when either URL is unusable, so an unparseable value never authorises a send + */ + fun sameOrigin(a: String?, b: String?): Boolean { + val first = (normalize(a) as? BaseUrlResult.Accepted)?.url ?: return false + val second = (normalize(b) as? BaseUrlResult.Accepted)?.url ?: return false + return originOf(first) == originOf(second) + } + + /** The `scheme://authority` part of an already-normalized URL. */ + private fun originOf(url: String): String { + val scheme = url.substringBefore("://") + val authority = url.substringAfter("://").substringBefore('/') + return "$scheme://$authority" + } + + /** + * 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/CatalogResult.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/CatalogResult.kt new file mode 100644 index 00000000..b3eb1f81 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/CatalogResult.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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. Either list may be empty, which many compatible servers do. + * + * Both halves come from one listing, so they describe the same snapshot of the same server — + * see `ModelCatalogFilter` for how one raw catalog splits into two pickers. + * + * @param models the chat models the server offers + * @param embeddingModels the embedding models the server offers + */ + data class Success( + val models: List, + val embeddingModels: 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeCatalogGateway.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeCatalogGateway.kt new file mode 100644 index 00000000..88b5cb81 --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeCatalogGateway.kt @@ -0,0 +1,122 @@ +package com.itsaky.androidide.plugins.aiagentclaude.settings + +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.aiagentclaude.backend.ModelCatalog +import com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeBackend +import com.itsaky.androidide.plugins.aiagentclaude.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.aiagentclaude.plugin.ClaudePlugin +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 ClaudeCatalogGateway { + + /** + * 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 +} + +/** + * [ClaudeCatalogGateway] over this plugin's own [ClaudeBackend]. + * + * 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 BackendClaudeCatalogGateway( + private val backendProvider: () -> ClaudeBackend? = ClaudePlugin::getBackend +) : ClaudeCatalogGateway { + + companion object { + private const val TAG = "$LOG_PREFIX.ClaudeCatalogGateway" + + /** + * 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() = ClaudePlugin.getContext()?.logger + + override fun listModelsForSavedSettings(): CatalogResult = + await { it.listCatalog() } + + override fun listModels(apiKey: String, baseUrl: String): CatalogResult = + await { it.listCatalog(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: (ClaudeBackend) -> 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 { + val catalog = future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ?: ModelCatalog.EMPTY + CatalogResult.Success(catalog.chat, catalog.embedding) + } 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeKeyOnboarding.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeKeyOnboarding.kt new file mode 100644 index 00000000..584fb45d --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeKeyOnboarding.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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 ClaudeKeyOnboarding { + + /** + * 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/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt new file mode 100644 index 00000000..34075bee --- /dev/null +++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt @@ -0,0 +1,1146 @@ +package com.itsaky.androidide.plugins.aiagentclaude.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.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.Button +import android.widget.EditText +import android.widget.Filter +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.DimenRes +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputLayout +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiagentclaude.R +import com.itsaky.androidide.plugins.aiagentclaude.plugin.ClaudePlugin +import com.itsaky.androidide.plugins.aiagentclaude.ui.SecretRevealController +import com.itsaky.androidide.plugins.aiagentclaude.ui.applyPaneStyling +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.security.KeystoreSecretStore +import com.itsaky.androidide.plugins.services.IdeTooltipService +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** The pane's secondary actions, drawn outlined; each section's Save stays filled. */ +private val OUTLINED_BUTTON_IDS = setOf( + R.id.btn_clear_api_key, + R.id.btn_edit_api_key, + R.id.btn_get_key, + R.id.btn_test_connection, +) + +/** + * This backend's settings pane, mounted by whichever screen offers a backend selector. + * + * Named to the host through `ClaudeBackend.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 ClaudeSettingsFragment : Fragment() { + + private lateinit var viewModel: ClaudeSettingsViewModel + 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 + + /** + * The API key field's reveal control, held so the key can be re-masked when this pane leaves + * the foreground. Captures views, so it is dropped in [onDestroyView] like [onPaneResume]. + */ + private var apiKeyReveal: SecretRevealController? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + try { + tooltipService = PluginFragmentHelper.getServiceRegistry(ClaudePlugin.PLUGIN_ID) + ?.get(IdeTooltipService::class.java) + } catch (e: Exception) { + // Tooltip help is optional; long-press simply shows nothing when it's unavailable. + ClaudePlugin.getContext()?.logger + ?.warn("ClaudeSettingsFragment: 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(ClaudePlugin.PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_claude_settings, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider( + this, + ClaudeSettingsViewModelFactory { ClaudePlugin.getContext() } + )[ClaudeSettingsViewModel::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. + view.applyPaneStyling(OUTLINED_BUTTON_IDS) + setupApiKeyUi(view) + setupServerUi(view) + setupModelPicker(view, chatModelPicker()) + setupModelPicker(view, embeddingModelPicker()) + setupConnectionTest(view) + onServerChanged?.invoke(viewModel.getBaseUrl()) + } + + override fun onResume() { + super.onResume() + onPaneResume?.invoke() + } + + /** + * Re-mask a revealed key on the way out of the foreground. + * + * Re-masking rather than only clearing [WindowManager.LayoutParams.FLAG_SECURE]: the flag has + * to go, since the window outlives this pane and nothing else would clear it, and dropping it + * over a legible key is what would let the recents thumbnail keep a copy of it. + */ + override fun onPause() { + apiKeyReveal?.mask() + super.onPause() + } + + override fun onDestroyView() { + // Drops the captured pane views along with the callbacks. + onPaneResume = null + onServerChanged = null + apiKeyReveal = 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, ClaudePlugin.TOOLTIP_CATEGORY, tag) + true + } + } + + /** + * Long-press on [box]'s end icon shows [tag]'s tooltip. + * + * Separate from [wireTooltip] because the end icon is a clickable child that consumes the + * long-press before the box sees it — without this every end icon on the pane, the reveal + * control and the dropdown chevrons alike, would be a contributed element with no tooltip of + * its own. + */ + private fun wireEndIconTooltip(box: TextInputLayout, tag: String) { + box.setEndIconOnLongClickListener { icon -> + val service = tooltipService ?: return@setEndIconOnLongClickListener false + service.showTooltip(icon, ClaudePlugin.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 + } + + /** Sets [view]'s top margin to [dimenRes], for a gap that depends on what else is showing. */ + private fun setTopMargin(view: View, @DimenRes dimenRes: Int) { + val params = view.layoutParams as? ViewGroup.MarginLayoutParams ?: return + val margin = resources.getDimensionPixelSize(dimenRes) + if (params.topMargin == margin) return + params.topMargin = margin + view.layoutParams = params + } + + /** 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) + } + + + /** + * Put the dropdown chevron on [box]'s end icon. + * + * The drawable is set here rather than in the layout because an end icon declared as + * `app:endIconDrawable` draws blank inside the host — the same reason [SecretRevealController] + * sets the reveal icon in code. Without a visible chevron the field + * reads as a plain, read-only text box rather than a list to open. + */ + private fun setupDropdownEndIcon(box: TextInputLayout) { + // Already the mode declared in the layout; set again so the drawable below cannot be the + // one a later mode switch discards. + box.endIconMode = TextInputLayout.END_ICON_CUSTOM + box.setEndIconDrawable(R.drawable.ic_dropdown) + // Nothing ever moves the icon's checked state, so TalkBack would read "not checked" over + // a control that is not a toggle. + box.isEndIconCheckable = false + } + + // --- Server ------------------------------------------------------------------------------- + + private fun setupServerUi(view: View) { + val presetBox = view.findViewById(R.id.claude_preset_box) + val presetInput = view.findViewById(R.id.claude_preset_input) + val urlInput = view.findViewById(R.id.claude_base_url_input) + val saveButton = view.findViewById