diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ad9905..dff93854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- Added OpenAI-compatible proxy: profiles can now route Claude Code through any OpenAI-compatible API provider (OpenAI, Azure, Groq, DeepSeek, Ollama, local models, etc.) + # 1.1.7 (2026-05-22) - Fixed profile combo not refreshing when profiles are added or removed after session start diff --git a/CLAUDE.md b/CLAUDE.md index 101fd4a8..bb67a538 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,7 +176,8 @@ The plugin follows an MVC structure for session management: | `ui/common/` | Shared input components: `AtCompletionPopup` — @-triggered path popup; `AtPathHighlighter` — blue token highlight; `FileDropHandler` — DnD + Ctrl+V → @path insertion; `ShortcutMatcher` — key→shortcut match with KEY_TYPED suppression; `TextComponentDecorator` — wires all the above; `DecoratedTextArea/TextField`, `TextContextMenu`, `RangeHighlightable` | | `settings/` | `ClaudeCodePreferences` — NbPreferences wrapper (keys: `contextMenuSessionMode` default CONTINUE_LAST, `sessionListLimit` default 30); `ClaudeCodeOptionsPanelController` / `ClaudeCodeOptionsPanel` — Tools→Options (General + Profiles tabs); `ClaudeProfile`, `ClaudeProfileStore` — named profiles with isolated `CLAUDE_CONFIG_DIR`, auth, proxy, extra env vars; `ClaudeProjectProperties` — per-project profile assignment | | `actions/` | `ClaudeCodeAction` — toolbar button; `OpenWithClaudeAction` — project node context menu; `PromptFavoriteAction` — dynamically registered action per favorite for Keymap API | -| `io.github.nbplugins.claudecodegui.mcp` | `MCPSseServer` — Jetty `/sse` `/messages` `/hook`; `NetBeansMCPHandler` — MCP dispatcher + PreToolUse hook; `tools/` — `OpenDiff`, `PermissionPromptTool`, `DiffTabTracker`, `GetDiagnostics`, `GetOpenEditors`, `GetCurrentSelection`, `OpenFile` | +| `io.github.nbplugins.claudecodegui.mcp` | `MCPSseServer` — Jetty `/sse` `/messages` `/hook` `/openai-proxy/*`; `NetBeansMCPHandler` — MCP dispatcher + PreToolUse hook; `tools/` — `OpenDiff`, `PermissionPromptTool`, `DiffTabTracker`, `GetDiagnostics`, `GetOpenEditors`, `GetCurrentSelection`, `OpenFile` | +| `io.github.nbplugins.claudecodegui.openaiproxy` | `OpenAIProxyConfig` — per-session config snapshot; `OpenAIProxyServlet` — translates Anthropic → OpenAI requests and responses; `AnthropicToOpenAITranslator` — stateless translation logic including streaming SSE state machine | | `org.openbeans.claude.netbeans` | **Legacy package — do not add new code here and avoid modifying existing code.** Contains: `ClaudeCodeStatusService`, `ClaudeCodeStatusLineElement`, `EditorUtils`, `NbUtils`; `tools/` — `AsyncHandler`, `AsyncResponse`, `GetWorkspaceFolders`, `CheckDocumentDirty`, `SaveDocument`, `CloseTab`, `CloseAllDiffTabs` | ### Session lifecycle @@ -255,6 +256,8 @@ After any action the originating `ClaudeSessionTab` is re-activated automaticall | "Start new session when opening with Claude" checkbox | `contextMenuSessionMode` | `CONTINUE_LAST` | Checked → `SessionMode.NEW`; unchecked → `SessionMode.CONTINUE_LAST`. Controls the mode used by the toolbar button and project context menu. | | "Session list limit" spinner (1–500) | `sessionListLimit` | 30 | Max number of past sessions shown in the resume list. | +**Note:** Changes to profile connection parameters (API key, base URL, proxy settings) take effect only after restarting the session. This applies to all connection types, including OpenAI Compatible Proxy. + ### Registration `layer.xml` registers the Options category (position 1500) and toolbar action. Icon PNGs are generated at build time from `cc-gui-icon.svg` via the Groovy script in `pom.xml` using Apache Batik. diff --git a/README.md b/README.md index 9d4dcf03..72061277 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ NetBeans Claude Code GUI is a NetBeans IDE plugin that embeds the Claude Code CL - **Prompt history and favorites** — recall past prompts with Ctrl+Up/Down; save reusable prompts as favorites with optional keyboard shortcuts - **File attachments** — attach files via `@path` tokens with auto-completion popup, drag-and-drop, and clipboard paste support - **Multiple profiles** — run Claude Code under separate accounts or API keys for different projects, each with an isolated config directory, authentication, proxy, and model settings +- **OpenAI-compatible proxy** — route Claude Code through any OpenAI-compatible provider (OpenAI, Azure OpenAI, Groq, DeepSeek, Ollama, LM Studio, and others) via a built-in translation proxy; no changes to Claude Code CLI required - **Session management** — start new sessions, continue the last session, or resume a specific past session; sessions persist across IDE restarts - **Markdown Preview** — live-rendered markdown tab for plan files and MCP-initiated previews; includes a find bar (Ctrl+F) and font zoom (Alt+Scroll) - **Auto Plan Preview** — when Claude writes a plan file, a live preview tab opens automatically as soon as you accept the diff diff --git a/docs/screenshots/profiles-tab.png b/docs/screenshots/profiles-tab.png index b80de9f5..9bdabdf1 100644 Binary files a/docs/screenshots/profiles-tab.png and b/docs/screenshots/profiles-tab.png differ diff --git a/docs/testing-openai-proxy-opencode-zen.md b/docs/testing-openai-proxy-opencode-zen.md new file mode 100644 index 00000000..3e234ad8 --- /dev/null +++ b/docs/testing-openai-proxy-opencode-zen.md @@ -0,0 +1,60 @@ +# Testing OpenAI Compatible Proxy with OpenCode Zen + +OpenCode Zen provides free OpenAI-compatible models that can be used to test the proxy feature without spending money. + +## 1. Registration and API Key + +1. Go to https://opencode.ai/auth +2. Sign in with Google or GitHub +3. Add billing details (a card is required even for free models — no charges for free-tier usage) +4. Copy your API key from the account dashboard + +## 2. Plugin Profile Setup + +Open **Tools → Options → Claude Code → Profiles**, create a new profile: + +| Field | Value | +|---|---| +| Connection type | OpenAI Compatible Proxy | +| Base URL | `https://opencode.ai/zen` | +| API Key | your key from step 1 | + +> **Note:** do not add `/v1` to the Base URL — the plugin appends it automatically when routing requests (`/v1/chat/completions`) and fetching models (`/v1/models`). + +## 3. Free Models + +Click **Model Aliases** in the profile editor. Use **Fetch** to load the current model list, or add manually: + +| Model name | Model ID | +|---|---| +| Big Pickle | `opencode/big-pickle` | +| DeepSeek V4 Flash Free | `opencode/deepseek-v4-flash-free` | +| Nemotron 3 Super Free | `opencode/nemotron-3-super-free` | + +All three models are $0 for input and output, context window 200K+. The list is time-limited while model teams collect feedback — check https://opencode.ai/docs/zen/ for the current list. + +Map Claude model tiers to OpenCode models, for example: + +| Alias (Claude tier) | Target model | +|---|---| +| `claude-sonnet-4-5` | `opencode/big-pickle` | +| `claude-haiku-4-5` | `opencode/deepseek-v4-flash-free` | +| `claude-opus-4-7` | `opencode/nemotron-3-super-free` | + +Claude Code sends requests using names like `claude-sonnet-4-5` — Model Aliases redirects them to the actual provider model. Without an alias the request will fail because OpenCode does not recognize Claude model names. + +## 4. Start a Session + +Start a Claude Code session with this profile. The plugin will: +- Set `ANTHROPIC_BASE_URL` to the internal proxy (`http://127.0.0.1:/openai-proxy/`) +- Translate Anthropic Messages API requests → OpenAI Chat Completions +- Translate responses back, including streaming SSE and tool use + +## 5. What to Verify + +- **Basic conversation** — send a simple question, verify a response arrives +- **Streaming** — response should appear incrementally, not all at once +- **Tool use** — ask Claude to read or edit a file, verify the tool_use → tool_result round-trip works +- **Debug logging** — enable debug mode in preferences, then check `~/.netbeans/28/var/log/messages.log` for lines showing request/response metadata (headers, model, token counts) with no message content +- **Two simultaneous sessions** — open two sessions with the same profile, verify both work independently +- **Profile edit during session** — change the API key while a session is running, verify the running session is unaffected; restart to apply the new key diff --git a/docs/user-manual.md b/docs/user-manual.md index d1f61b82..8c6f9af4 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -78,7 +78,7 @@ Contains the input area, **▶ Send** / **✖ Cancel** buttons, and **☰ Histor Press **Shift+Tab** anywhere in the prompt panel to cycle through modes. -**Model selector** — lists the models available in the current session. Standard entries (`sonnet`, `opus`, `haiku`) are discovered by opening the `/model` menu. For **Other API** profiles, any models assigned the `custom` alias in **Model Aliases** also appear here, shown by their full provider ID. Selecting a model switches to it immediately. +**Model selector** — lists the models available in the current session. Standard entries (`sonnet`, `opus`, `haiku`) are discovered by opening the `/model` menu. For **Claude-compatible API** profiles, any models assigned the `custom` alias in **Model Aliases** also appear here, shown by their full provider ID. Selecting a model switches to it immediately. On the right side of the status bar: @@ -487,9 +487,12 @@ Select the authentication type using the radio buttons: | **Claude Managed** | None | Claude manages authentication itself. Run `claude` in a terminal once to set it up, or Claude will prompt you on first launch. Use `/login` in the session terminal to re-authenticate. | | **Subscription** | OAuth token | Obtain the token at [claude.ai](https://claude.ai) (requires Pro, Max, Team, or Enterprise subscription). **Note:** OAuth tokens expire periodically and must be regenerated and re-entered manually. For a smoother experience, prefer **Claude Managed** instead. | | **Claude API** | API key | Obtain the key at [console.anthropic.com](https://console.anthropic.com) (requires an Anthropic Console account with API access). | -| **Other API** | API key + Base URL | Use the API key and base URL provided by your third-party provider. | +| **Claude-compatible API** | API key + Base URL | Use the API key and base URL provided by your third-party provider (Anthropic-compatible endpoint). | +| **OpenAI-compatible API** | API key + Base URL | Route Claude Code through any OpenAI-compatible provider. The plugin starts an internal proxy that translates Anthropic API calls to OpenAI Chat Completions format. See [OpenAI-compatible API](#openai-compatible-api) below. | -### Model aliases (Other API only) +> **Note:** Changes to connection parameters (API key, Base URL) take effect only after restarting the session. + +### Model aliases (Claude-compatible API only) If your provider names models differently from Anthropic's standard names, the plugin cannot match them to the `sonnet`, `opus`, and `haiku` aliases used by Claude Code. In that case, set the alias to the actual model ID used by your provider (for example, with an `anthropic/` prefix). @@ -546,6 +549,49 @@ The dialog shows a table with three columns: **Note:** Claude Code communicates with the plugin via `localhost`. External proxies are not aware of this. If you fill in the **NO_PROXY** field, make sure it includes `localhost` — otherwise the plugin integration will stop working. If you leave the field empty, the plugin adds `localhost` automatically. +### OpenAI-compatible API + +Claude Code sends requests in Anthropic Messages API format. Many AI providers — including OpenAI, Azure OpenAI, Groq, DeepSeek, and local models via Ollama or LM Studio — use OpenAI Chat Completions format instead. The **OpenAI-compatible API** connection type bridges this gap by running an internal HTTP proxy inside the plugin. + +**How it works:** + +1. When a session starts, the plugin registers an internal proxy path within the already-running MCP server. +2. Claude Code is directed to send requests to that path (`ANTHROPIC_BASE_URL=http://127.0.0.1:/openai-proxy/`). +3. The proxy translates each Anthropic Messages API request to OpenAI Chat Completions format, forwards it to your provider, and translates the response back — including streaming and tool calls. +4. When the session stops, the proxy path is deregistered. No separate port or process is used. + +**Setup:** + +1. Create a new profile and select **OpenAI-compatible API** as the connection type. +2. Fill in **Base URL** — the base URL of your provider's API (e.g. `https://api.openai.com/v1`, `http://localhost:11434/v1` for Ollama). +3. Fill in **API Key** — the API key for your provider (enter any non-blank value if the provider does not require a key). +4. Open **Model Aliases…** and map Claude model tiers to your provider's model names: + +| Alias | Provider model example | +|-------|----------------------| +| `sonnet` | `gpt-4o` | +| `opus` | `gpt-4o` | +| `haiku` | `gpt-4o-mini` | + +Without model aliases, Claude Code will send Anthropic model names (e.g. `claude-sonnet-4-5`) to your provider. Most OpenAI-compatible providers will reject these — configure aliases to avoid errors. + +**Provider examples:** + +| Provider | Base URL | Notes | +|----------|----------|-------| +| OpenAI | `https://api.openai.com/v1` | Standard OpenAI key | +| Azure OpenAI | `https://.openai.azure.com/openai/deployments/` | Azure API key | +| Groq | `https://api.groq.com/openai/v1` | Groq API key | +| DeepSeek | `https://api.deepseek.com/v1` | DeepSeek API key | +| Ollama (local) | `http://localhost:11434/v1` | Any value for API key | +| LM Studio (local) | `http://localhost:1234/v1` | Any value for API key | + +**Limitations:** + +- Not all OpenAI-compatible models support all Claude Code features (tool use, large context windows). Using an incompatible model may cause errors. +- Images in conversations (base64 content) are forwarded in OpenAI vision format; provider must support it. +- Changes to the profile's Base URL or API Key take effect only after restarting the session. + ### Extra environment variables Arbitrary `KEY=VALUE` pairs injected into the Claude process environment. Applied last — they override all other profile variables. Use this for provider-specific configuration. diff --git a/pom.xml b/pom.xml index 23c24755..ea4b004c 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.1.4-SNAPSHOT + 1.2.7-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java b/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java index b75a4f56..3246cff3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java @@ -247,4 +247,24 @@ public int getServerPort() { public boolean isLockFileValid() { return false; // lock file no longer used } + + @Override + public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, + io.github.nbplugins.claudecodegui.settings.ProxyConfiguration proxy) { + if (mcpServer != null) { + mcpServer.registerOpenAIProxy(uuid, baseUrl, apiKey, proxy); + } + } + + @Override + public void deregisterOpenAIProxy(String uuid) { + if (mcpServer != null) { + mcpServer.deregisterOpenAIProxy(uuid); + } + } + + @Override + public io.github.nbplugins.claudecodegui.openaiproxy.OpenAIProxyConfig getOpenAIProxyConfig(String uuid) { + return mcpServer != null ? mcpServer.getOpenAIProxyConfig(uuid) : null; + } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java index f0ff0818..8dc675f0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java @@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.nbplugins.claudecodegui.openaiproxy.OpenAIProxyConfig; +import io.github.nbplugins.claudecodegui.openaiproxy.OpenAIProxyServlet; +import io.github.nbplugins.claudecodegui.settings.ProxyConfiguration; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.servlet.ServletContextHandler; @@ -62,6 +65,13 @@ public class MCPSseServer { private final ConcurrentHashMap> sessionQueues = new ConcurrentHashMap<>(); + /** + * Registry of active OpenAI-compatible proxy sessions, keyed by UUID. + * Each entry is a config snapshot taken at session start. + */ + private final ConcurrentHashMap openAIProxies = + new ConcurrentHashMap<>(); + /** * Creates a new MCPSseServer backed by the given MCP handler. * @@ -103,6 +113,7 @@ public boolean start(int port) { ctx.addServlet(hookHolder, "/hook"); ctx.addServlet(new ServletHolder(new StopServlet()), "/stop"); ctx.addServlet(new ServletHolder(new PermissionRequestServlet()), "/permission-request"); + ctx.addServlet(new ServletHolder(new OpenAIProxyServlet(this)), "/openai-proxy/*"); server.setHandler(ctx); server.setRequestLog((request, response) -> @@ -141,6 +152,43 @@ public void stop() { */ public int getPort() { return port; } + // ------------------------------------------------------------------------- + // OpenAI proxy registry + // ------------------------------------------------------------------------- + + /** + * Registers an OpenAI-compatible proxy session. + * + * @param uuid unique session identifier + * @param baseUrl provider base URL + * @param apiKey provider API key + * @param proxy proxy settings from the profile (used by the servlet's HttpClient) + */ + public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, ProxyConfiguration proxy) { + openAIProxies.put(uuid, new OpenAIProxyConfig(baseUrl, apiKey, proxy)); + LOGGER.log(Level.FINE, "OpenAI proxy registered: uuid={0}", uuid); + } + + /** + * Deregisters an OpenAI-compatible proxy session. + * + * @param uuid unique session identifier + */ + public void deregisterOpenAIProxy(String uuid) { + openAIProxies.remove(uuid); + LOGGER.log(Level.FINE, "OpenAI proxy deregistered: uuid={0}", uuid); + } + + /** + * Returns the proxy config for the given UUID, or {@code null} if not found. + * + * @param uuid unique session identifier + * @return config snapshot or {@code null} + */ + public OpenAIProxyConfig getOpenAIProxyConfig(String uuid) { + return openAIProxies.get(uuid); + } + /** * Returns {@code true} while the Jetty server is running. * diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java new file mode 100644 index 00000000..00d743e0 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -0,0 +1,723 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Iterator; +import java.util.Map; + +/** + * Stateless translator between Anthropic Messages API format and + * OpenAI Chat Completions API format. + * + *

Based on open-source reference implementations: + *

    + *
  • https://github.com/1rgs/claude-code-proxy (server.py)
  • + *
  • https://github.com/fuergaosi233/claude-code-proxy
  • + *
+ * + *

Request (Anthropic → OpenAI)

+ *
    + *
  • {@code system} field → prepended as {@code {"role":"system",...}} message
  • + *
  • User text content blocks → flattened to string
  • + *
  • User tool_result blocks → converted to {@code role:"tool"} messages
  • + *
  • Assistant tool_use blocks → converted to {@code tool_calls}
  • + *
  • Image blocks → converted to OpenAI vision format
  • + *
  • {@code tools} → wrapped in {@code {"type":"function","function":{...}}}
  • + *
  • {@code max_tokens} → capped at 16384 for non-Claude models
  • + *
+ * + *

Response non-streaming (OpenAI → Anthropic)

+ * Converts {@code choices[0].message} back to Anthropic content blocks. + * + *

Response streaming

+ * State machine converting OpenAI SSE chunks to Anthropic SSE event sequence. + */ +public final class AnthropicToOpenAITranslator { + + static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final int MAX_TOKENS_NON_CLAUDE = 16384; + + private AnthropicToOpenAITranslator() {} + + // ------------------------------------------------------------------------- + // Request translation + // ------------------------------------------------------------------------- + + /** + * Translates an Anthropic {@code POST /v1/messages} request body to an + * OpenAI {@code POST /chat/completions} request body. + * + * @param anthropicRequest parsed Anthropic request JSON + * @return OpenAI-format request JSON + */ + public static ObjectNode translateRequest(JsonNode anthropicRequest) { + ObjectNode openai = MAPPER.createObjectNode(); + + // Model — pass as-is; user configures ANTHROPIC_DEFAULT_*_MODEL aliases + String model = anthropicRequest.path("model").asText(""); + openai.put("model", model); + + // max_tokens + if (anthropicRequest.has("max_tokens")) { + int maxTokens = anthropicRequest.get("max_tokens").asInt(); + if (!model.startsWith("claude")) { + maxTokens = Math.min(maxTokens, MAX_TOKENS_NON_CLAUDE); + } + openai.put("max_tokens", maxTokens); + } + + // stream + if (anthropicRequest.has("stream")) { + openai.put("stream", anthropicRequest.get("stream").asBoolean()); + } + + // temperature, top_p (pass through if present) + for (String field : new String[]{"temperature", "top_p"}) { + if (anthropicRequest.has(field)) { + openai.set(field, anthropicRequest.get(field)); + } + } + + // Messages + ArrayNode messages = openai.putArray("messages"); + + // System prompt → first message + JsonNode systemNode = anthropicRequest.path("system"); + if (!systemNode.isMissingNode() && !systemNode.isNull()) { + String systemText = extractSystemText(systemNode); + if (!systemText.isBlank()) { + ObjectNode sysMsg = messages.addObject(); + sysMsg.put("role", "system"); + sysMsg.put("content", systemText); + } + } + + // Anthropic messages + JsonNode anthropicMessages = anthropicRequest.path("messages"); + if (anthropicMessages.isArray()) { + convertMessages(anthropicMessages, messages); + } + + // Tools + JsonNode tools = anthropicRequest.path("tools"); + if (tools.isArray() && !tools.isEmpty()) { + ArrayNode openaiTools = openai.putArray("tools"); + convertTools(tools, openaiTools); + } + + // tool_choice + JsonNode toolChoice = anthropicRequest.path("tool_choice"); + if (!toolChoice.isMissingNode() && !toolChoice.isNull()) { + openai.set("tool_choice", convertToolChoice(toolChoice)); + } + + return openai; + } + + private static String extractSystemText(JsonNode systemNode) { + if (systemNode.isTextual()) { + return systemNode.asText(); + } + if (systemNode.isArray()) { + StringBuilder sb = new StringBuilder(); + for (JsonNode block : systemNode) { + if ("text".equals(block.path("type").asText())) { + if (sb.length() > 0) sb.append("\n\n"); + sb.append(block.path("text").asText()); + } + } + return sb.toString(); + } + return ""; + } + + private static void convertMessages(JsonNode anthropicMessages, ArrayNode openaiMessages) { + for (JsonNode msg : anthropicMessages) { + String role = msg.path("role").asText(); + JsonNode content = msg.path("content"); + + if ("user".equals(role)) { + convertUserMessage(content, openaiMessages); + } else if ("assistant".equals(role)) { + convertAssistantMessage(content, openaiMessages); + } + } + } + + private static void convertUserMessage(JsonNode content, ArrayNode out) { + if (content.isTextual()) { + ObjectNode msg = out.addObject(); + msg.put("role", "user"); + msg.put("content", content.asText()); + return; + } + + if (!content.isArray()) return; + + // Check if this is purely tool_result blocks — they become separate tool messages + boolean allToolResults = true; + for (JsonNode block : content) { + if (!"tool_result".equals(block.path("type").asText())) { + allToolResults = false; + break; + } + } + + if (allToolResults) { + for (JsonNode block : content) { + ObjectNode toolMsg = out.addObject(); + toolMsg.put("role", "tool"); + toolMsg.put("tool_call_id", block.path("tool_use_id").asText()); + toolMsg.put("content", extractToolResultContent(block.path("content"))); + } + return; + } + + // Mixed user content — check for images + boolean hasImage = false; + for (JsonNode block : content) { + if ("image".equals(block.path("type").asText())) { + hasImage = true; + break; + } + } + + if (!hasImage) { + // Text only — flatten to string + StringBuilder sb = new StringBuilder(); + for (JsonNode block : content) { + if ("text".equals(block.path("type").asText())) { + sb.append(block.path("text").asText()); + } + } + ObjectNode msg = out.addObject(); + msg.put("role", "user"); + msg.put("content", sb.toString()); + } else { + // Multimodal + ObjectNode msg = out.addObject(); + msg.put("role", "user"); + ArrayNode parts = msg.putArray("content"); + for (JsonNode block : content) { + String type = block.path("type").asText(); + if ("text".equals(type)) { + ObjectNode part = parts.addObject(); + part.put("type", "text"); + part.put("text", block.path("text").asText()); + } else if ("image".equals(type)) { + JsonNode src = block.path("source"); + String mediaType = src.path("media_type").asText("image/jpeg"); + String data = src.path("data").asText(); + ObjectNode part = parts.addObject(); + part.put("type", "image_url"); + part.putObject("image_url").put("url", "data:" + mediaType + ";base64," + data); + } + } + } + } + + private static String extractToolResultContent(JsonNode contentNode) { + if (contentNode.isTextual()) return contentNode.asText(); + if (contentNode.isNull() || contentNode.isMissingNode()) return ""; + if (contentNode.isArray()) { + StringBuilder sb = new StringBuilder(); + for (JsonNode block : contentNode) { + if ("text".equals(block.path("type").asText())) { + sb.append(block.path("text").asText()); + } + } + if (sb.length() > 0) return sb.toString(); + } + // Fallback: JSON serialize + return contentNode.toString(); + } + + private static void convertAssistantMessage(JsonNode content, ArrayNode out) { + if (content.isTextual()) { + ObjectNode msg = out.addObject(); + msg.put("role", "assistant"); + msg.put("content", content.asText()); + return; + } + + if (!content.isArray()) return; + + StringBuilder textBuffer = new StringBuilder(); + StringBuilder reasoningBuffer = new StringBuilder(); + ArrayNode toolCalls = null; + + for (JsonNode block : content) { + String type = block.path("type").asText(); + if ("text".equals(type)) { + textBuffer.append(block.path("text").asText()); + } else if ("thinking".equals(type)) { + reasoningBuffer.append(block.path("thinking").asText()); + } else if ("tool_use".equals(type)) { + if (toolCalls == null) { + toolCalls = MAPPER.createArrayNode(); + } + ObjectNode tc = toolCalls.addObject(); + tc.put("id", block.path("id").asText()); + tc.put("type", "function"); + ObjectNode fn = tc.putObject("function"); + fn.put("name", block.path("name").asText()); + JsonNode input = block.path("input"); + fn.put("arguments", input.isObject() ? input.toString() : "{}"); + } + } + + ObjectNode msg = out.addObject(); + msg.put("role", "assistant"); + if (reasoningBuffer.length() > 0) { + msg.put("reasoning_content", reasoningBuffer.toString()); + } + if (textBuffer.length() > 0) { + msg.put("content", textBuffer.toString()); + } else { + msg.putNull("content"); + } + if (toolCalls != null) { + msg.set("tool_calls", toolCalls); + } + } + + private static void convertTools(JsonNode anthropicTools, ArrayNode openaiTools) { + for (JsonNode tool : anthropicTools) { + ObjectNode oTool = openaiTools.addObject(); + oTool.put("type", "function"); + ObjectNode fn = oTool.putObject("function"); + fn.put("name", tool.path("name").asText()); + if (tool.has("description")) { + fn.put("description", tool.path("description").asText()); + } + JsonNode schema = tool.path("input_schema"); + if (!schema.isMissingNode()) { + fn.set("parameters", schema); + } + } + } + + private static JsonNode convertToolChoice(JsonNode toolChoice) { + if (toolChoice.isTextual()) { + String val = toolChoice.asText(); + return switch (val) { + case "any" -> MAPPER.getNodeFactory().textNode("required"); + case "auto" -> MAPPER.getNodeFactory().textNode("auto"); + case "none" -> MAPPER.getNodeFactory().textNode("none"); + default -> MAPPER.getNodeFactory().textNode("auto"); + }; + } + if (toolChoice.isObject()) { + String type = toolChoice.path("type").asText(); + if ("tool".equals(type)) { + ObjectNode choice = MAPPER.createObjectNode(); + choice.put("type", "function"); + choice.putObject("function").put("name", toolChoice.path("name").asText()); + return choice; + } + } + return MAPPER.getNodeFactory().textNode("auto"); + } + + // ------------------------------------------------------------------------- + // Response translation (non-streaming) + // ------------------------------------------------------------------------- + + /** + * Translates an OpenAI Chat Completions response to Anthropic Messages response format. + * + * @param openaiResponse parsed OpenAI response JSON + * @param model model name to include in response + * @return Anthropic-format response JSON + */ + public static ObjectNode translateResponse(JsonNode openaiResponse, String model) { + ObjectNode anthropic = MAPPER.createObjectNode(); + + anthropic.put("id", openaiResponse.path("id").asText("msg_proxy")); + anthropic.put("type", "message"); + anthropic.put("role", "assistant"); + anthropic.put("model", model); + + ArrayNode content = anthropic.putArray("content"); + JsonNode choices = openaiResponse.path("choices"); + String stopReason = "end_turn"; + + if (choices.isArray() && !choices.isEmpty()) { + JsonNode choice = choices.get(0); + JsonNode message = choice.path("message"); + + // Thinking / reasoning content (DeepSeek-style providers) + JsonNode rc = message.path("reasoning_content"); + if (!rc.isNull() && !rc.isMissingNode() && !rc.asText().isBlank()) { + ObjectNode tb = content.addObject(); + tb.put("type", "thinking"); + tb.put("thinking", rc.asText()); + tb.put("signature", "proxy_thinking_sig"); + } + + // Text content + JsonNode textContent = message.path("content"); + if (!textContent.isNull() && !textContent.isMissingNode()) { + String text = textContent.asText(); + if (!text.isBlank()) { + ObjectNode textBlock = content.addObject(); + textBlock.put("type", "text"); + textBlock.put("text", text); + } + } + + // Tool calls + JsonNode toolCalls = message.path("tool_calls"); + if (toolCalls.isArray()) { + for (JsonNode tc : toolCalls) { + ObjectNode toolUse = content.addObject(); + toolUse.put("type", "tool_use"); + toolUse.put("id", tc.path("id").asText()); + toolUse.put("name", tc.path("function").path("name").asText()); + String argsStr = tc.path("function").path("arguments").asText("{}"); + try { + toolUse.set("input", MAPPER.readTree(argsStr)); + } catch (Exception e) { + toolUse.putObject("input"); + } + } + } + + // finish_reason + stopReason = mapFinishReason(choice.path("finish_reason").asText("stop")); + } + + anthropic.put("stop_reason", stopReason); + anthropic.putNull("stop_sequence"); + + // Usage + ObjectNode usage = anthropic.putObject("usage"); + JsonNode openaiUsage = openaiResponse.path("usage"); + usage.put("input_tokens", openaiUsage.path("prompt_tokens").asInt(0)); + usage.put("output_tokens", openaiUsage.path("completion_tokens").asInt(0)); + + return anthropic; + } + + static String mapFinishReason(String openaiReason) { + return switch (openaiReason) { + case "length" -> "max_tokens"; + case "tool_calls", + "function_call" -> "tool_use"; + default -> "end_turn"; + }; + } + + // ------------------------------------------------------------------------- + // Streaming state machine + // ------------------------------------------------------------------------- + + /** + * Mutable per-request state for the streaming SSE translator. + * Create one instance per streaming request. + */ + public static final class StreamingState { + + private boolean messageStarted = false; + private boolean thinkingBlockOpen = false; + private int thinkingBlockIndex = -1; + private boolean textBlockOpen = false; + private int textBlockIndex = -1; + private int nextBlockIndex = 0; + private String stopReason = "end_turn"; + private int outputTokens = 0; + private int inputTokens = 0; + private String messageId = "msg_proxy"; + private String model = ""; + + // Per OpenAI tool_call index: {id, name, started} + private final java.util.Map toolCalls = new java.util.LinkedHashMap<>(); + + private static final class ToolCallState { + String id; + String name; + boolean started; + int blockIndex; + } + + /** + * Process one OpenAI SSE data line and return zero or more Anthropic SSE events. + * + * @param dataLine the raw data line (without the {@code "data: "} prefix) + * @return string of Anthropic SSE events to write to the client, may be empty + */ + public String processChunk(String dataLine) { + if ("[DONE]".equals(dataLine)) { + return buildDoneEvents(); + } + + JsonNode chunk; + try { + chunk = MAPPER.readTree(dataLine); + } catch (Exception e) { + return ""; + } + + StringBuilder out = new StringBuilder(); + + if (!messageStarted) { + messageId = chunk.path("id").asText("msg_proxy"); + model = chunk.path("model").asText(""); + out.append(sseEvent("message_start", buildMessageStart())); + out.append(sseEvent("ping", "{\"type\":\"ping\"}")); + messageStarted = true; + } + + JsonNode choices = chunk.path("choices"); + if (!choices.isArray() || choices.isEmpty()) { + // Might be a usage-only chunk + JsonNode usage = chunk.path("usage"); + if (!usage.isMissingNode()) { + outputTokens = usage.path("completion_tokens").asInt(outputTokens); + inputTokens = usage.path("prompt_tokens").asInt(inputTokens); + } + return out.toString(); + } + + JsonNode choice = choices.get(0); + JsonNode delta = choice.path("delta"); + + // Track finish_reason + String fr = choice.path("finish_reason").asText(null); + if (fr != null && !"null".equals(fr)) { + stopReason = mapFinishReason(fr); + } + + // Usage in this chunk (some providers send it here) + JsonNode usage = chunk.path("usage"); + if (!usage.isMissingNode()) { + outputTokens = usage.path("completion_tokens").asInt(outputTokens); + inputTokens = usage.path("prompt_tokens").asInt(inputTokens); + } + + // Reasoning (thinking) delta — DeepSeek/OpenCode style + JsonNode reasoningNode = delta.path("reasoning_content"); + if (!reasoningNode.isNull() && !reasoningNode.isMissingNode()) { + String rc = reasoningNode.asText(); + if (!rc.isEmpty()) { + if (!thinkingBlockOpen) { + thinkingBlockIndex = nextBlockIndex++; + out.append(sseEvent("content_block_start", + buildThinkingBlockStart(thinkingBlockIndex))); + thinkingBlockOpen = true; + } + out.append(sseEvent("content_block_delta", + buildThinkingDelta(thinkingBlockIndex, rc))); + } + } + + // Text delta + JsonNode contentNode = delta.path("content"); + if (!contentNode.isNull() && !contentNode.isMissingNode()) { + String text = contentNode.asText(); + if (!text.isEmpty()) { + // Close thinking block before opening text block + if (thinkingBlockOpen) { + out.append(sseEvent("content_block_delta", + buildSignatureDelta(thinkingBlockIndex))); + out.append(sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + thinkingBlockIndex + "}")); + thinkingBlockOpen = false; + } + if (!textBlockOpen) { + textBlockIndex = nextBlockIndex++; + out.append(sseEvent("content_block_start", + buildTextBlockStart(textBlockIndex))); + textBlockOpen = true; + } + out.append(sseEvent("content_block_delta", + buildTextDelta(textBlockIndex, text))); + } + } + + // Tool call deltas + JsonNode toolCallsNode = delta.path("tool_calls"); + if (toolCallsNode.isArray()) { + for (JsonNode tc : toolCallsNode) { + int tcIdx = tc.path("index").asInt(0); + ToolCallState state = toolCalls.computeIfAbsent(tcIdx, k -> new ToolCallState()); + + String tcId = tc.path("id").asText(null); + String tcName = tc.path("function").path("name").asText(null); + if (tcId != null) state.id = tcId; + if (tcName != null) state.name = tcName; + + if (!state.started && state.id != null && state.name != null) { + // Close thinking block if open + if (thinkingBlockOpen) { + out.append(sseEvent("content_block_delta", + buildSignatureDelta(thinkingBlockIndex))); + out.append(sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + thinkingBlockIndex + "}")); + thinkingBlockOpen = false; + } + // Close text block if open + if (textBlockOpen) { + out.append(sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + textBlockIndex + "}")); + textBlockOpen = false; + } + state.blockIndex = nextBlockIndex++; + out.append(sseEvent("content_block_start", + buildToolUseBlockStart(state.blockIndex, state.id, state.name))); + state.started = true; + } + + String argsFrag = tc.path("function").path("arguments").asText(null); + if (argsFrag != null && !argsFrag.isEmpty() && state.started) { + out.append(sseEvent("content_block_delta", + buildInputJsonDelta(state.blockIndex, argsFrag))); + } + } + } + + return out.toString(); + } + + private String buildDoneEvents() { + StringBuilder out = new StringBuilder(); + if (thinkingBlockOpen) { + out.append(sseEvent("content_block_delta", buildSignatureDelta(thinkingBlockIndex))); + out.append(sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + thinkingBlockIndex + "}")); + } + if (textBlockOpen) { + out.append(sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + textBlockIndex + "}")); + } + for (ToolCallState state : toolCalls.values()) { + if (state.started) { + out.append(sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + state.blockIndex + "}")); + } + } + out.append(sseEvent("message_delta", buildMessageDelta())); + out.append(sseEvent("message_stop", "{\"type\":\"message_stop\"}")); + return out.toString(); + } + + private String buildMessageStart() { + return String.format( + "{\"type\":\"message_start\",\"message\":{\"id\":\"%s\",\"type\":\"message\"," + + "\"role\":\"assistant\",\"content\":[],\"model\":\"%s\"," + + "\"stop_reason\":null,\"stop_sequence\":null," + + "\"usage\":{\"input_tokens\":%d,\"output_tokens\":0}}}", + messageId, model, inputTokens); + } + + private static String buildTextBlockStart(int index) { + return String.format( + "{\"type\":\"content_block_start\",\"index\":%d," + + "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}", + index); + } + + private static String buildTextDelta(int index, String text) { + String escaped = text.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r") + .replace("\t", "\\t"); + return String.format( + "{\"type\":\"content_block_delta\",\"index\":%d," + + "\"delta\":{\"type\":\"text_delta\",\"text\":\"%s\"}}", + index, escaped); + } + + private static String buildToolUseBlockStart(int index, String id, String name) { + return String.format( + "{\"type\":\"content_block_start\",\"index\":%d," + + "\"content_block\":{\"type\":\"tool_use\",\"id\":\"%s\",\"name\":\"%s\",\"input\":{}}}", + index, id, name); + } + + private static String buildInputJsonDelta(int index, String partialJson) { + String escaped = partialJson.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r"); + return String.format( + "{\"type\":\"content_block_delta\",\"index\":%d," + + "\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"%s\"}}", + index, escaped); + } + + private static String buildThinkingBlockStart(int index) { + return String.format( + "{\"type\":\"content_block_start\",\"index\":%d," + + "\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}", + index); + } + + private static String buildThinkingDelta(int index, String thinking) { + String escaped = thinking.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r") + .replace("\t", "\\t"); + return String.format( + "{\"type\":\"content_block_delta\",\"index\":%d," + + "\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"%s\"}}", + index, escaped); + } + + private static String buildSignatureDelta(int index) { + return String.format( + "{\"type\":\"content_block_delta\",\"index\":%d," + + "\"delta\":{\"type\":\"signature_delta\",\"signature\":\"proxy_thinking_sig\"}}", + index); + } + + private String buildMessageDelta() { + return String.format( + "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"%s\"," + + "\"stop_sequence\":null},\"usage\":{\"output_tokens\":%d}}", + stopReason, outputTokens); + } + } + + // ------------------------------------------------------------------------- + // SSE helpers + // ------------------------------------------------------------------------- + + static String sseEvent(String eventType, String dataJson) { + return "event: " + eventType + "\ndata: " + dataJson + "\n\n"; + } + + // ------------------------------------------------------------------------- + // Debug summary helpers (no conversation content) + // ------------------------------------------------------------------------- + + /** Returns a one-line summary of an Anthropic request for debug logging. */ + public static String summarizeAnthropicRequest(JsonNode req) { + int msgCount = req.path("messages").size(); + int toolCount = req.path("tools").size(); + return "model=" + req.path("model").asText("?") + + " stream=" + req.path("stream").asBoolean(false) + + " max_tokens=" + req.path("max_tokens").asInt(0) + + " messages=" + msgCount + + " tools=" + toolCount; + } + + /** Returns a one-line summary of an OpenAI request for debug logging. */ + public static String summarizeOpenAIRequest(JsonNode req) { + int msgCount = req.path("messages").size(); + int toolCount = req.path("tools").size(); + return "model=" + req.path("model").asText("?") + + " stream=" + req.path("stream").asBoolean(false) + + " max_tokens=" + req.path("max_tokens").asInt(0) + + " messages=" + msgCount + + " tools=" + toolCount; + } + + /** Returns a one-line summary of an OpenAI non-streaming response for debug logging. */ + public static String summarizeOpenAIResponse(JsonNode resp) { + JsonNode usage = resp.path("usage"); + JsonNode choice = resp.path("choices").path(0); + return "finish_reason=" + choice.path("finish_reason").asText("?") + + " prompt_tokens=" + usage.path("prompt_tokens").asInt(0) + + " completion_tokens=" + usage.path("completion_tokens").asInt(0); + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java new file mode 100644 index 00000000..8b65bb60 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java @@ -0,0 +1,41 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import io.github.nbplugins.claudecodegui.settings.ProxyConfiguration; +import java.net.http.HttpClient; +import java.time.Duration; + +/** + * Snapshot of OpenAI-compatible endpoint configuration taken at session start. + * + *

Immutable after construction. Passed to {@link OpenAIProxyServlet} via + * {@link io.github.nbplugins.claudecodegui.mcp.MCPSseServer} registry. + * Changes to the profile after session start do not affect a running session. + */ +public final class OpenAIProxyConfig { + + private final String baseUrl; + private final String apiKey; + private final ProxyConfiguration proxy; + + public OpenAIProxyConfig(String baseUrl, String apiKey, ProxyConfiguration proxy) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.proxy = proxy; + } + + /** Base URL of the OpenAI-compatible endpoint (e.g. {@code https://api.openai.com/v1}). */ + public String getBaseUrl() { return baseUrl; } + + /** API key sent as {@code Authorization: Bearer }. */ + public String getApiKey() { return apiKey; } + + /** + * Builds an {@link HttpClient} configured with the proxy settings from this config. + * See {@link ProxyConfiguration#applyTo} for details. + */ + public HttpClient buildHttpClient() { + return proxy.applyTo( + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)) + ).build(); + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java new file mode 100644 index 00000000..0e528e96 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java @@ -0,0 +1,274 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.nbplugins.claudecodegui.mcp.MCPSseServer; +import io.github.nbplugins.claudecodegui.settings.ClaudeCodePreferences; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Enumeration; +import java.util.logging.Logger; + +// Duration used in HttpRequest timeout + +/** + * Servlet registered at {@code /openai-proxy/*} that translates + * Anthropic Messages API requests to OpenAI Chat Completions format + * and translates responses back. + * + *

The URL structure is: + *

  /openai-proxy/{uuid}/v1/messages
+ * where {@code uuid} identifies the session and maps to an + * {@link OpenAIProxyConfig} stored in the shared {@link MCPSseServer}. + * + *

Both streaming and non-streaming modes are supported. + */ +public final class OpenAIProxyServlet extends HttpServlet { + + private static final Logger LOG = Logger.getLogger(OpenAIProxyServlet.class.getName()); + + private final MCPSseServer mcpServer; + + // HttpClient is built per-request from the session's OpenAIProxyConfig, + // which contains the proxy settings from the profile. + + public OpenAIProxyServlet(MCPSseServer mcpServer) { + this.mcpServer = mcpServer; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + // Extract UUID from path: /openai-proxy//v1/messages + String pathInfo = req.getPathInfo(); // e.g. "/abc-123/v1/messages" + if (pathInfo == null || pathInfo.length() < 2) { + resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Missing session UUID in path"); + return; + } + String[] parts = pathInfo.substring(1).split("/", 2); // ["abc-123", "v1/messages"] + String uuid = parts[0]; + + OpenAIProxyConfig config = mcpServer.getOpenAIProxyConfig(uuid); + if (config == null) { + resp.sendError(HttpServletResponse.SC_NOT_FOUND, + "No active proxy session for UUID: " + uuid); + return; + } + + boolean debug = ClaudeCodePreferences.isDebugMode(); + + // Read Anthropic request body + String body = readBody(req); + JsonNode anthropicReq; + try { + anthropicReq = AnthropicToOpenAITranslator.MAPPER.readTree(body); + } catch (Exception e) { + LOG.severe("OpenAI proxy: failed to parse Anthropic request: " + e.getMessage()); + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid JSON"); + return; + } + + boolean streaming = anthropicReq.path("stream").asBoolean(false); + String model = anthropicReq.path("model").asText("unknown"); + + if (debug) { + StringBuilder sb = new StringBuilder("OpenAI proxy ← Anthropic request: "); + sb.append(AnthropicToOpenAITranslator.summarizeAnthropicRequest(anthropicReq)); + sb.append(" | headers:"); + Enumeration headerNames = req.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String h = headerNames.nextElement(); + sb.append(" ").append(h).append("=").append(req.getHeader(h)); + } + LOG.info(sb.toString()); + } + + // Translate request + ObjectNode openaiReq = AnthropicToOpenAITranslator.translateRequest(anthropicReq); + String openaiBody = AnthropicToOpenAITranslator.MAPPER.writeValueAsString(openaiReq); + + String targetUrl = config.getBaseUrl(); + if (!targetUrl.endsWith("/")) targetUrl += "/"; + targetUrl += "v1/chat/completions"; + + if (debug) { + LOG.info("OpenAI proxy → OpenAI request: url=" + targetUrl + + " Authorization=Bearer [REDACTED]" + + " | " + AnthropicToOpenAITranslator.summarizeOpenAIRequest(openaiReq)); + } + + HttpRequest httpReq = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + config.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(openaiBody, StandardCharsets.UTF_8)) + .timeout(Duration.ofSeconds(600)) + .build(); + + HttpClient httpClient = config.buildHttpClient(); + if (streaming) { + handleStreaming(httpClient, httpReq, model, resp, debug); + } else { + handleNonStreaming(httpClient, httpReq, model, resp, debug); + } + } + + private void handleNonStreaming(HttpClient httpClient, HttpRequest httpReq, String model, + HttpServletResponse resp, boolean debug) throws IOException { + HttpResponse httpResp; + try { + httpResp = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + resp.sendError(HttpServletResponse.SC_BAD_GATEWAY, "Proxy request interrupted"); + return; + } + + int status = httpResp.statusCode(); + + if (status >= 400) { + String errBody = httpResp.body(); + LOG.severe("OpenAI proxy: provider returned HTTP " + status + ": " + errBody); + resp.setStatus(status); + resp.setContentType("application/json"); + resp.getWriter().write(toAnthropicError(errBody, status)); + return; + } + + JsonNode openaiResp; + try { + openaiResp = AnthropicToOpenAITranslator.MAPPER.readTree(httpResp.body()); + } catch (Exception e) { + LOG.severe("OpenAI proxy: failed to parse provider response: " + e.getMessage()); + resp.sendError(HttpServletResponse.SC_BAD_GATEWAY, "Invalid JSON from provider"); + return; + } + + if (debug) { + StringBuilder sb = new StringBuilder("OpenAI proxy ← OpenAI response: status=" + status); + httpResp.headers().map().forEach((k, v) -> + sb.append(" ").append(k).append("=").append(String.join(",", v))); + sb.append(" | ").append(AnthropicToOpenAITranslator.summarizeOpenAIResponse(openaiResp)); + LOG.info(sb.toString()); + } + + ObjectNode anthropicResp = AnthropicToOpenAITranslator.translateResponse(openaiResp, model); + + if (debug) { + LOG.info("OpenAI proxy → Anthropic response: stop_reason=" + + anthropicResp.path("stop_reason").asText() + + " input_tokens=" + anthropicResp.path("usage").path("input_tokens").asInt() + + " output_tokens=" + anthropicResp.path("usage").path("output_tokens").asInt()); + } + + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + resp.getWriter().write(AnthropicToOpenAITranslator.MAPPER.writeValueAsString(anthropicResp)); + } + + private void handleStreaming(HttpClient httpClient, HttpRequest httpReq, String model, + HttpServletResponse resp, boolean debug) throws IOException { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/event-stream"); + resp.setCharacterEncoding("UTF-8"); + resp.setHeader("Cache-Control", "no-cache"); + resp.setHeader("Connection", "keep-alive"); + resp.setHeader("X-Accel-Buffering", "no"); + + PrintWriter writer = resp.getWriter(); + + HttpResponse httpResp; + try { + httpResp = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofInputStream()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + int status = httpResp.statusCode(); + if (status >= 400) { + String errBody = new String(httpResp.body().readAllBytes(), StandardCharsets.UTF_8); + LOG.severe("OpenAI proxy: provider returned HTTP " + status + " (streaming): " + errBody); + writer.write("event: error\ndata: " + toAnthropicError(errBody, status) + "\n\n"); + writer.flush(); + return; + } + + AnthropicToOpenAITranslator.StreamingState state = + new AnthropicToOpenAITranslator.StreamingState(); + + int chunkCount = 0; + boolean firstChunk = true; + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(httpResp.body(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("data: ")) { + String data = line.substring(6).trim(); + if (firstChunk && debug) { + LOG.info("OpenAI proxy: first streaming chunk received"); + firstChunk = false; + } + String events = state.processChunk(data); + if (!events.isEmpty()) { + writer.write(events); + writer.flush(); + } + if (!"[DONE]".equals(data)) chunkCount++; + } + } + } + + if (debug) { + LOG.info("OpenAI proxy: streaming done, total chunks=" + chunkCount); + } + writer.flush(); + } + + /** + * Converts a provider error body to Anthropic error JSON string. + * Handles both Anthropic-format ({@code {"type":"error","error":{...}}}) + * and OpenAI-format ({@code {"error":{...}}}) provider responses. + */ + static String toAnthropicError(String providerBody, int httpStatus) { + try { + JsonNode json = AnthropicToOpenAITranslator.MAPPER.readTree(providerBody); + // Already Anthropic format + if (json.has("type") && json.has("error")) { + return providerBody; + } + // OpenAI format: {"error":{"message":"...","type":"..."}} + if (json.has("error")) { + JsonNode err = json.get("error"); + String msg = err.path("message").asText("Provider error " + httpStatus); + String type = err.path("type").asText("api_error"); + String escaped = msg.replace("\\", "\\\\").replace("\"", "\\\""); + return "{\"type\":\"error\",\"error\":{\"type\":\"" + type + "\",\"message\":\"" + escaped + "\"}}"; + } + } catch (Exception ignored) {} + return "{\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"Provider returned HTTP " + httpStatus + "\"}}"; + } + + private static String readBody(HttpServletRequest req) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = req.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + } + return sb.toString(); + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java index 9b917b1e..92f0bea4 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java @@ -20,6 +20,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import org.openbeans.claude.netbeans.ClaudeCodeStatusService; @@ -78,6 +79,9 @@ public ClaudeProcess() {} /** Temp file holding the --mcp-config JSON; {@code null} when not in use. */ private volatile Path mcpConfigTempFile; + /** UUID of the active OpenAI proxy session; {@code null} when not using OpenAI proxy. */ + private volatile String openAIProxyUuid; + /** * Starts a Claude CLI PTY process in the given working directory using * the Default profile (no extra env vars injected). @@ -155,7 +159,28 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl Map env = buildEnv(profile, ClaudeCodePreferences.getProfilesDir()); - boolean apiKeyHelper = profile != null && !profile.getApiKey().isBlank() && profile.getBaseUrl().isBlank(); + // OpenAI proxy: register session and inject ANTHROPIC_BASE_URL pointing to internal proxy + if (profile != null + && profile.computeConnectionType() == ClaudeProfile.ConnectionType.OPENAI_PROXY) { + ClaudeCodeStatusService mcpSvc = Lookup.getDefault().lookup(ClaudeCodeStatusService.class); + if (mcpSvc != null && mcpSvc.isServerRunning()) { + String uuid = UUID.randomUUID().toString(); + mcpSvc.registerOpenAIProxy(uuid, profile.getBaseUrl(), profile.getApiKey(), + io.github.nbplugins.claudecodegui.settings.ProxyConfiguration.from(profile)); + openAIProxyUuid = uuid; + env.put("ANTHROPIC_BASE_URL", + "http://127.0.0.1:" + mcpSvc.getServerPort() + "/openai-proxy/" + uuid); + env.put("ANTHROPIC_AUTH_TOKEN", "sk-proxy-internal"); + LOG.info("OpenAI proxy registered: uuid=" + uuid + + ", profile=" + profile.getName() + + ", target=" + profile.getBaseUrl()); + } else { + LOG.warning("OpenAI proxy: MCP server not running — proxy cannot be started"); + } + } + + boolean apiKeyHelper = profile != null && !profile.getApiKey().isBlank() && profile.getBaseUrl().isBlank() + && profile.computeConnectionType() != ClaudeProfile.ConnectionType.OPENAI_PROXY; LOG.info("Starting Claude: profile=" + (profile != null ? profile.getName() + " (" + profile.computeConnectionType() + ")" : "Default") + ", apiKeyHelper=" + (apiKeyHelper ? "SET" : "NOT SET") + ", ANTHROPIC_AUTH_TOKEN=" + (!env.getOrDefault("ANTHROPIC_AUTH_TOKEN", "").isBlank() ? "SET" : "NOT SET") @@ -313,6 +338,17 @@ public void killOnly() { public void stop() { killOnly(); + // Deregister OpenAI proxy session if active + String proxyUuid = openAIProxyUuid; + openAIProxyUuid = null; + if (proxyUuid != null) { + ClaudeCodeStatusService mcpSvc = Lookup.getDefault().lookup(ClaudeCodeStatusService.class); + if (mcpSvc != null) { + mcpSvc.deregisterOpenAIProxy(proxyUuid); + LOG.fine("OpenAI proxy deregistered: uuid=" + proxyUuid); + } + } + Path tmp = mcpConfigTempFile; mcpConfigTempFile = null; if (tmp != null) { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java index cfa5965c..d4fb3743 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java @@ -51,7 +51,13 @@ public enum ConnectionType { CLAUDE_API, /** Third-party or custom API endpoint — injects {@code ANTHROPIC_AUTH_TOKEN} * and {@code ANTHROPIC_BASE_URL}. */ - OTHER_API + OTHER_API, + /** + * OpenAI-compatible provider routed through the internal proxy. + * The proxy is started by {@code ClaudeProcess} and injects its own + * {@code ANTHROPIC_BASE_URL}; no env vars are set here. + */ + OPENAI_PROXY } /** @@ -157,6 +163,13 @@ public enum ProxyMode { */ private String storageDir; + /** + * When {@code true}, this profile uses the internal OpenAI-compatible proxy. + * {@link #computeConnectionType()} returns {@link ConnectionType#OPENAI_PROXY}. + * {@link #baseUrl} and {@link #apiKey} hold the OpenAI endpoint URL and API key. + */ + private boolean openaiProxy; + // ------------------------------------------------------------------------- // No-arg constructor (Jackson) // ------------------------------------------------------------------------- @@ -235,6 +248,9 @@ public boolean isDefault() { * @return the connection type inferred from current field values */ public ConnectionType computeConnectionType() { + if (openaiProxy) { + return ConnectionType.OPENAI_PROXY; + } if (!isBlank(apiKey)) { return isBlank(baseUrl) ? ConnectionType.CLAUDE_API : ConnectionType.OTHER_API; } @@ -266,13 +282,14 @@ public Map toEnvVars() { // Auth switch (computeConnectionType()) { - case SUBSCRIPTION -> env.put("CLAUDE_CODE_OAUTH_TOKEN", blankToEmpty(token)); - case CLAUDE_API -> { /* API key is written to settings.local.json as apiKeyHelper */ } - case OTHER_API -> { + case SUBSCRIPTION -> env.put("CLAUDE_CODE_OAUTH_TOKEN", blankToEmpty(token)); + case CLAUDE_API -> { /* API key is written to settings.local.json as apiKeyHelper */ } + case OTHER_API -> { env.put("ANTHROPIC_AUTH_TOKEN", blankToEmpty(apiKey)); env.put("ANTHROPIC_BASE_URL", blankToEmpty(baseUrl)); } - default -> { /* CLAUDE_MANAGED — nothing to inject */ } + case OPENAI_PROXY -> { /* ANTHROPIC_BASE_URL is injected by ClaudeProcess after proxy start */ } + default -> { /* CLAUDE_MANAGED — nothing to inject */ } } // Proxy @@ -584,6 +601,12 @@ public ClaudeProfile withStorageDir(String storageDir) { return this; } + /** Returns {@code true} if this profile uses the internal OpenAI-compatible proxy. */ + public boolean isOpenaiProxy() { return openaiProxy; } + + /** Sets whether this profile uses the internal OpenAI-compatible proxy. */ + public void setOpenaiProxy(boolean openaiProxy) { this.openaiProxy = openaiProxy; } + // ------------------------------------------------------------------------- // Object // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java index 50059da8..16c8b1b0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java @@ -67,7 +67,8 @@ public final class ClaudeProfilesPanel extends JPanel { private static final String CONN_MANAGED = "Claude managed"; private static final String CONN_SUBSCRIPTION = "Subscription"; private static final String CONN_CLAUDE_API = "Claude API"; - private static final String CONN_OTHER_API = "Other API"; + private static final String CONN_OTHER_API = "Claude-compatible API"; + private static final String CONN_OPENAI_PROXY = "OpenAI-compatible API"; private static final String PROXY_CARD_NONE = "proxy_none"; private static final String PROXY_CARD_CUSTOM = "proxy_custom"; @@ -106,6 +107,9 @@ public final class ClaudeProfilesPanel extends JPanel { private JRadioButton rbClaudeApi; /** Radio button for other (custom base URL) API connection. */ private JRadioButton rbOtherApi; + /** Radio button for OpenAI-compatible proxy connection. */ + private JRadioButton rbOpenAIProxy; + /** Info label shown when OpenAI proxy is selected. */ /** Password field for the OAuth token. */ private JPasswordField tokenField; /** Password field for the Anthropic API key. */ @@ -290,8 +294,9 @@ private JPanel buildConnectionTypeSection() { rbSubscription = new JRadioButton(CONN_SUBSCRIPTION); rbClaudeApi = new JRadioButton(CONN_CLAUDE_API); rbOtherApi = new JRadioButton(CONN_OTHER_API); + rbOpenAIProxy = new JRadioButton(CONN_OPENAI_PROXY); ButtonGroup bg = new ButtonGroup(); - for (JRadioButton rb : new JRadioButton[]{rbManaged, rbSubscription, rbClaudeApi, rbOtherApi}) { + for (JRadioButton rb : new JRadioButton[]{rbManaged, rbSubscription, rbClaudeApi, rbOtherApi, rbOpenAIProxy}) { bg.add(rb); } @@ -339,6 +344,7 @@ private JPanel buildConnectionTypeSection() { rbSubscription.addActionListener(e -> updateFieldEnablement()); rbClaudeApi.addActionListener(e -> updateFieldEnablement()); rbOtherApi.addActionListener(e -> updateFieldEnablement()); + rbOpenAIProxy.addActionListener(e -> updateFieldEnablement()); // Grid: col0=radio, col1=label, col2=field(fill), col3=button JPanel grid = new JPanel(new GridBagLayout()); @@ -375,6 +381,9 @@ private JPanel buildConnectionTypeSection() { grid.add(baseUrlField, inlineFieldGbc(2, 3)); grid.add(modelAliasesBtn, inlineBtnGbc(3, 3)); + // row 4: rbOpenAIProxy — fields (baseUrlField, apiKeyField) are shared with rows 2-3 + grid.add(rbOpenAIProxy, inlineRbGbc(0, 4)); + JPanel outer = new JPanel(new BorderLayout(0, 4)); outer.add(new JLabel("Connection Type:"), BorderLayout.NORTH); outer.add(grid, BorderLayout.CENTER); @@ -382,14 +391,15 @@ private JPanel buildConnectionTypeSection() { } private void updateFieldEnablement() { - boolean sub = rbSubscription.isSelected(); - boolean api = rbClaudeApi.isSelected() || rbOtherApi.isSelected(); - boolean otherApi = rbOtherApi.isSelected(); + boolean sub = rbSubscription.isSelected(); + boolean api = rbClaudeApi.isSelected() || rbOtherApi.isSelected() || rbOpenAIProxy.isSelected(); + boolean otherApi = rbOtherApi.isSelected(); + boolean openai = rbOpenAIProxy.isSelected(); tokenField.setEnabled(sub); tokenShowBtn.setEnabled(sub); apiKeyField.setEnabled(api); apiKeyShowBtn.setEnabled(api); - baseUrlField.setEditable(otherApi); + baseUrlField.setEditable(otherApi || openai); if (claudeAiBtn != null) { claudeAiBtn.setVisible(sub); consoleAnthropicBtn.setVisible(rbClaudeApi.isSelected()); @@ -398,7 +408,7 @@ private void updateFieldEnablement() { } private void updateModelAliasesBtn() { - boolean other = rbOtherApi.isSelected(); + boolean other = rbOtherApi.isSelected() || rbOpenAIProxy.isSelected(); boolean hasKey = !new String(apiKeyField.getPassword()).isBlank(); boolean hasUrl = !baseUrlField.getText().isBlank(); modelAliasesBtn.setEnabled(other && hasKey && hasUrl); @@ -582,18 +592,27 @@ private void flushFormToCurrentProfile() { p.setToken(new String(tokenField.getPassword())); p.setApiKey(""); p.setBaseUrl(""); + p.setOpenaiProxy(false); } else if (rbClaudeApi.isSelected()) { p.setApiKey(new String(apiKeyField.getPassword())); p.setToken(""); p.setBaseUrl(""); + p.setOpenaiProxy(false); } else if (rbOtherApi.isSelected()) { p.setApiKey(new String(apiKeyField.getPassword())); p.setBaseUrl(baseUrlField.getText().trim()); p.setToken(""); + p.setOpenaiProxy(false); + } else if (rbOpenAIProxy.isSelected()) { + p.setApiKey(new String(apiKeyField.getPassword())); + p.setBaseUrl(baseUrlField.getText().trim()); + p.setToken(""); + p.setOpenaiProxy(true); } else { p.setToken(""); p.setApiKey(""); p.setBaseUrl(""); + p.setOpenaiProxy(false); } // Proxy @@ -658,6 +677,11 @@ private void loadProfileIntoForm(ClaudeProfile p) { apiKeyField.setText(p.getApiKey()); baseUrlField.setText(p.getBaseUrl()); } + case OPENAI_PROXY -> { + rbOpenAIProxy.setSelected(true); + apiKeyField.setText(p.getApiKey()); + baseUrlField.setText(p.getBaseUrl()); + } default -> rbManaged.setSelected(true); } updateFieldEnablement(); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ProxyConfiguration.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ProxyConfiguration.java new file mode 100644 index 00000000..718d7289 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ProxyConfiguration.java @@ -0,0 +1,77 @@ +package io.github.nbplugins.claudecodegui.settings; + +import io.github.nbplugins.claudecodegui.settings.ClaudeProfile.ProxyMode; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; + +/** + * Immutable snapshot of HTTP proxy settings taken from a {@link ClaudeProfile}. + * + *

Used to configure {@link HttpClient} instances in components that make + * outbound HTTP requests on behalf of a session (e.g. the OpenAI-compatible proxy servlet). + */ +public final class ProxyConfiguration { + + private final ProxyMode mode; + private final String httpProxy; + private final String httpsProxy; + private final String noProxy; + + public ProxyConfiguration(ProxyMode mode, String httpProxy, String httpsProxy, String noProxy) { + this.mode = mode != null ? mode : ProxyMode.SYSTEM_MANAGED; + this.httpProxy = httpProxy != null ? httpProxy : ""; + this.httpsProxy = httpsProxy != null ? httpsProxy : ""; + this.noProxy = noProxy != null ? noProxy : ""; + } + + /** Creates a snapshot from a profile's current proxy settings. */ + public static ProxyConfiguration from(ClaudeProfile profile) { + return new ProxyConfiguration( + profile.getProxyMode(), + profile.getHttpProxy(), + profile.getHttpsProxy(), + profile.getNoProxy()); + } + + public ProxyMode getMode() { return mode; } + public String getHttpProxy() { return httpProxy; } + public String getHttpsProxy() { return httpsProxy; } + public String getNoProxy() { return noProxy; } + + /** + * Configures an {@link HttpClient.Builder} with this proxy configuration. + *

    + *
  • {@link ProxyMode#SYSTEM_MANAGED} — uses the JVM default proxy selector
  • + *
  • {@link ProxyMode#NO_PROXY} — disables proxying
  • + *
  • {@link ProxyMode#CUSTOM} — uses the HTTPS proxy URL, or HTTP proxy as fallback
  • + *
+ * + * @param builder the builder to configure (modified in place) + * @return the same builder, for chaining + */ + public HttpClient.Builder applyTo(HttpClient.Builder builder) { + switch (mode) { + case NO_PROXY -> + builder.proxy(HttpClient.Builder.NO_PROXY); + case CUSTOM -> { + String url = !httpsProxy.isBlank() ? httpsProxy + : !httpProxy.isBlank() ? httpProxy + : null; + if (url != null) { + try { + URI uri = URI.create(url); + int port = uri.getPort() > 0 ? uri.getPort() : 8080; + builder.proxy(ProxySelector.of(new InetSocketAddress(uri.getHost(), port))); + } catch (Exception e) { + builder.proxy(ProxySelector.getDefault()); + } + } + } + default -> // SYSTEM_MANAGED + builder.proxy(ProxySelector.getDefault()); + } + return builder; + } +} diff --git a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java index edfc1b96..72bc1577 100644 --- a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java +++ b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java @@ -1,5 +1,8 @@ package org.openbeans.claude.netbeans; +import io.github.nbplugins.claudecodegui.openaiproxy.OpenAIProxyConfig; +import io.github.nbplugins.claudecodegui.settings.ProxyConfiguration; + /** * Service interface for accessing Claude Code integration status. * This service is registered in the global lookup to provide status information @@ -30,8 +33,35 @@ public interface ClaudeCodeStatusService { /** * Checks if the lock file is valid and accessible. - * + * * @return true if lock file is valid, false otherwise */ boolean isLockFileValid(); + + /** + * Registers an OpenAI-compatible proxy session. + * Called by {@code ClaudeProcess} at session start. + * + * @param uuid unique session identifier + * @param baseUrl OpenAI-compatible endpoint base URL + * @param apiKey API key for the provider + * @param proxy proxy settings from the profile + */ + void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, ProxyConfiguration proxy); + + /** + * Deregisters an OpenAI-compatible proxy session. + * Called by {@code ClaudeProcess} at session stop. + * + * @param uuid unique session identifier previously passed to {@link #registerOpenAIProxy} + */ + void deregisterOpenAIProxy(String uuid); + + /** + * Returns the proxy config for the given session UUID, or {@code null} if not found. + * + * @param uuid unique session identifier + * @return config snapshot, or {@code null} + */ + OpenAIProxyConfig getOpenAIProxyConfig(String uuid); } \ No newline at end of file diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java new file mode 100644 index 00000000..9c89a4d8 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -0,0 +1,277 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link AnthropicToOpenAITranslator}. + * + *

JSON fixtures are in + * {@code src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/}. + */ +class AnthropicToOpenAITranslatorTest { + + private static JsonNode load(String name) throws Exception { + String path = "/io/github/nbplugins/claudecodegui/openaiproxy/" + name; + try (InputStream is = AnthropicToOpenAITranslatorTest.class.getResourceAsStream(path)) { + assertNotNull(is, "Missing fixture: " + path); + return AnthropicToOpenAITranslator.MAPPER.readTree( + new String(is.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + private static AnthropicToOpenAITranslator.StreamingState state() { + return new AnthropicToOpenAITranslator.StreamingState(); + } + + // ------------------------------------------------------------------------- + // translateRequest + // ------------------------------------------------------------------------- + + @Test + void translateRequest_simpleTextMessage() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_simple_text.json")); + + assertEquals("claude-sonnet-4-5", result.path("model").asText()); + assertEquals(1024, result.path("max_tokens").asInt()); + JsonNode messages = result.path("messages"); + assertEquals(1, messages.size()); + assertEquals("user", messages.get(0).path("role").asText()); + assertEquals("Hello", messages.get(0).path("content").asText()); + } + + @Test + void translateRequest_systemPromptPrependedAsFirstMessage() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_system_prompt.json")); + + JsonNode messages = result.path("messages"); + assertEquals(2, messages.size()); + assertEquals("system", messages.get(0).path("role").asText()); + assertEquals("You are helpful.", messages.get(0).path("content").asText()); + assertEquals("user", messages.get(1).path("role").asText()); + } + + @Test + void translateRequest_systemAsListOfBlocks() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_system_as_blocks.json")); + + JsonNode sysMsg = result.path("messages").get(0); + assertEquals("system", sysMsg.path("role").asText()); + assertEquals("Block one.\n\nBlock two.", sysMsg.path("content").asText()); + } + + @Test + void translateRequest_toolUseInAssistantMessage() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_tool_use_assistant.json")); + + JsonNode msg = result.path("messages").get(0); + assertEquals("assistant", msg.path("role").asText()); + assertTrue(msg.path("content").isNull()); + JsonNode toolCalls = msg.path("tool_calls"); + assertEquals(1, toolCalls.size()); + assertEquals("call_1", toolCalls.get(0).path("id").asText()); + assertEquals("function", toolCalls.get(0).path("type").asText()); + assertEquals("read_file", toolCalls.get(0).path("function").path("name").asText()); + String args = toolCalls.get(0).path("function").path("arguments").asText(); + assertTrue(args.contains("test.txt"), "arguments should contain path: " + args); + } + + @Test + void translateRequest_toolResultInUserMessage() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_tool_result_user.json")); + + JsonNode messages = result.path("messages"); + assertEquals(1, messages.size()); + JsonNode toolMsg = messages.get(0); + assertEquals("tool", toolMsg.path("role").asText()); + assertEquals("call_1", toolMsg.path("tool_call_id").asText()); + assertEquals("file content here", toolMsg.path("content").asText()); + } + + @Test + void translateRequest_toolResultWithListContent() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_tool_result_list_content.json")); + + JsonNode toolMsg = result.path("messages").get(0); + assertEquals("tool", toolMsg.path("role").asText()); + assertEquals("result text", toolMsg.path("content").asText()); + } + + @Test + void translateRequest_toolsConverted() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_tools.json")); + + JsonNode tools = result.path("tools"); + assertEquals(1, tools.size()); + JsonNode tool = tools.get(0); + assertEquals("function", tool.path("type").asText()); + assertEquals("read_file", tool.path("function").path("name").asText()); + assertEquals("Reads a file", tool.path("function").path("description").asText()); + assertTrue(tool.path("function").path("parameters").has("properties")); + } + + @Test + void translateRequest_maxTokensCappedForNonClaudeModel() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_max_tokens_non_claude.json")); + assertEquals(16384, result.path("max_tokens").asInt()); + } + + @Test + void translateRequest_maxTokensNotCappedForClaudeModel() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_max_tokens_claude.json")); + assertEquals(100000, result.path("max_tokens").asInt()); + } + + @Test + void translateRequest_mixedAssistantContent() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_mixed_assistant.json")); + + JsonNode msg = result.path("messages").get(0); + assertEquals("I will call the tool.", msg.path("content").asText()); + assertEquals(1, msg.path("tool_calls").size()); + } + + // ------------------------------------------------------------------------- + // translateResponse + // ------------------------------------------------------------------------- + + @Test + void translateResponse_textContent() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateResponse(load("resp_text.json"), "gpt-4o"); + + assertEquals("message", result.path("type").asText()); + assertEquals("assistant", result.path("role").asText()); + assertEquals("end_turn", result.path("stop_reason").asText()); + assertEquals(1, result.path("content").size()); + assertEquals("text", result.path("content").get(0).path("type").asText()); + assertEquals("Hello there!", result.path("content").get(0).path("text").asText()); + assertEquals(10, result.path("usage").path("input_tokens").asInt()); + assertEquals(5, result.path("usage").path("output_tokens").asInt()); + } + + @Test + void translateResponse_toolCalls() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateResponse(load("resp_tool_calls.json"), "gpt-4o"); + + assertEquals("tool_use", result.path("stop_reason").asText()); + JsonNode content = result.path("content"); + assertEquals(1, content.size()); + assertEquals("tool_use", content.get(0).path("type").asText()); + assertEquals("call_abc", content.get(0).path("id").asText()); + assertEquals("read_file", content.get(0).path("name").asText()); + assertEquals("/tmp/x.txt", content.get(0).path("input").path("path").asText()); + } + + @Test + void translateResponse_finishReasonMapping() { + assertEquals("end_turn", AnthropicToOpenAITranslator.mapFinishReason("stop")); + assertEquals("max_tokens", AnthropicToOpenAITranslator.mapFinishReason("length")); + assertEquals("tool_use", AnthropicToOpenAITranslator.mapFinishReason("tool_calls")); + assertEquals("tool_use", AnthropicToOpenAITranslator.mapFinishReason("function_call")); + assertEquals("end_turn", AnthropicToOpenAITranslator.mapFinishReason("unknown")); + } + + @Test + void translateRequest_thinkingBlockConverted() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_thinking_assistant.json")); + + JsonNode msg = result.path("messages").get(0); + assertEquals("assistant", msg.path("role").asText()); + assertEquals("Let me reason about this.", msg.path("reasoning_content").asText()); + assertEquals("Here is the answer.", msg.path("content").asText()); + } + + // ------------------------------------------------------------------------- + // StreamingState + // ------------------------------------------------------------------------- + + @Test + void streaming_textDelta() { + var s = state(); + + String first = s.processChunk( + "{\"id\":\"c1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"index\":0}]}"); + assertTrue(first.contains("message_start"), "expected message_start in: " + first); + assertTrue(first.contains("ping"), "expected ping in: " + first); + + String second = s.processChunk( + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"index\":0}]}"); + assertTrue(second.contains("content_block_start"), "expected content_block_start in: " + second); + assertTrue(second.contains("text_delta"), "expected text_delta in: " + second); + assertTrue(second.contains("Hello"), "expected Hello in: " + second); + + String done = s.processChunk("[DONE]"); + assertTrue(done.contains("content_block_stop"), "expected content_block_stop in: " + done); + assertTrue(done.contains("message_delta"), "expected message_delta in: " + done); + assertTrue(done.contains("message_stop"), "expected message_stop in: " + done); + } + + @Test + void streaming_toolCallDelta() { + var s = state(); + + s.processChunk("{\"id\":\"c1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}"); + + String chunk1 = s.processChunk( + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tc1\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\"}}]},\"index\":0}]}"); + assertTrue(chunk1.contains("content_block_start"), "expected content_block_start in: " + chunk1); + assertTrue(chunk1.contains("tool_use"), "expected tool_use in: " + chunk1); + assertTrue(chunk1.contains("tc1"), "expected id in: " + chunk1); + + String chunk2 = s.processChunk( + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"\"}}]},\"index\":0}]}"); + assertTrue(chunk2.contains("input_json_delta"), "expected input_json_delta in: " + chunk2); + + String done = s.processChunk("[DONE]"); + assertTrue(done.contains("content_block_stop"), "done should close tool block: " + done); + assertTrue(done.contains("message_stop"), "done should end message: " + done); + } + + @Test + void streaming_finishReasonInChunk() { + var s = state(); + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"hi\"},\"index\":0}]}"); + s.processChunk("{\"id\":\"c1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"length\",\"index\":0}]}"); + String done = s.processChunk("[DONE]"); + assertTrue(done.contains("max_tokens"), "expected max_tokens stop_reason in: " + done); + } + + @Test + void streaming_thinkingDelta() { + var s = state(); + + s.processChunk("{\"id\":\"c1\",\"model\":\"big-pickle\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}"); + + String chunk1 = s.processChunk( + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"reasoning_content\":\"Let me think.\"},\"index\":0}]}"); + assertTrue(chunk1.contains("content_block_start"), "expected thinking block start: " + chunk1); + assertTrue(chunk1.contains("thinking_delta"), "expected thinking_delta: " + chunk1); + assertTrue(chunk1.contains("Let me think."), "expected thinking text: " + chunk1); + + String chunk2 = s.processChunk( + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"Answer.\"},\"index\":0}]}"); + assertTrue(chunk2.contains("signature_delta"), "expected signature_delta before text: " + chunk2); + assertTrue(chunk2.contains("content_block_stop"), "expected thinking block stop: " + chunk2); + assertTrue(chunk2.contains("text_delta"), "expected text_delta: " + chunk2); + + String done = s.processChunk("[DONE]"); + assertTrue(done.contains("message_stop"), "expected message_stop: " + done); + } + + @Test + void streaming_invalidJsonIsNoop() { + var s = state(); + assertEquals("", s.processChunk("not-valid-json")); + } + + @Test + void sseEvent_format() { + String event = AnthropicToOpenAITranslator.sseEvent("ping", "{\"type\":\"ping\"}"); + assertEquals("event: ping\ndata: {\"type\":\"ping\"}\n\n", event); + } +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_max_tokens_claude.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_max_tokens_claude.json new file mode 100644 index 00000000..509971b8 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_max_tokens_claude.json @@ -0,0 +1,5 @@ +{ + "model": "claude-opus-4-7", + "max_tokens": 100000, + "messages": [{"role": "user", "content": "Hi"}] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_max_tokens_non_claude.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_max_tokens_non_claude.json new file mode 100644 index 00000000..d68232f5 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_max_tokens_non_claude.json @@ -0,0 +1,5 @@ +{ + "model": "gpt-4o", + "max_tokens": 100000, + "messages": [{"role": "user", "content": "Hi"}] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_mixed_assistant.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_mixed_assistant.json new file mode 100644 index 00000000..8bf76030 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_mixed_assistant.json @@ -0,0 +1,11 @@ +{ + "model": "gpt-4o", + "max_tokens": 512, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "text", "text": "I will call the tool."}, + {"type": "tool_use", "id": "t1", "name": "fn", "input": {}} + ] + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_simple_text.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_simple_text.json new file mode 100644 index 00000000..27789993 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_simple_text.json @@ -0,0 +1,5 @@ +{ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_as_blocks.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_as_blocks.json new file mode 100644 index 00000000..40f9cdba --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_as_blocks.json @@ -0,0 +1,9 @@ +{ + "model": "gpt-4o", + "max_tokens": 512, + "system": [ + {"type": "text", "text": "Block one."}, + {"type": "text", "text": "Block two."} + ], + "messages": [{"role": "user", "content": "Hi"}] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_prompt.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_prompt.json new file mode 100644 index 00000000..57ab2ee2 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_prompt.json @@ -0,0 +1,6 @@ +{ + "model": "gpt-4o", + "max_tokens": 512, + "system": "You are helpful.", + "messages": [{"role": "user", "content": "Hi"}] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_thinking_assistant.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_thinking_assistant.json new file mode 100644 index 00000000..932c4aae --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_thinking_assistant.json @@ -0,0 +1,11 @@ +{ + "model": "big-pickle", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me reason about this.", "signature": "orig_sig"}, + {"type": "text", "text": "Here is the answer."} + ] + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_result_list_content.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_result_list_content.json new file mode 100644 index 00000000..6d75f3f8 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_result_list_content.json @@ -0,0 +1,12 @@ +{ + "model": "gpt-4o", + "max_tokens": 512, + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "call_2", + "content": [{"type": "text", "text": "result text"}] + }] + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_result_user.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_result_user.json new file mode 100644 index 00000000..b0c5ff64 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_result_user.json @@ -0,0 +1,12 @@ +{ + "model": "gpt-4o", + "max_tokens": 512, + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "call_1", + "content": "file content here" + }] + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_use_assistant.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_use_assistant.json new file mode 100644 index 00000000..f0f139bb --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tool_use_assistant.json @@ -0,0 +1,13 @@ +{ + "model": "gpt-4o", + "max_tokens": 512, + "messages": [{ + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "call_1", + "name": "read_file", + "input": {"path": "/tmp/test.txt"} + }] + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tools.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tools.json new file mode 100644 index 00000000..773f919b --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_tools.json @@ -0,0 +1,13 @@ +{ + "model": "gpt-4o", + "max_tokens": 512, + "messages": [{"role": "user", "content": "use tool"}], + "tools": [{ + "name": "read_file", + "description": "Reads a file", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string"}} + } + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/resp_text.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/resp_text.json new file mode 100644 index 00000000..a3095aa5 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/resp_text.json @@ -0,0 +1,8 @@ +{ + "id": "chatcmpl-123", + "choices": [{ + "message": {"role": "assistant", "content": "Hello there!"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5} +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/resp_tool_calls.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/resp_tool_calls.json new file mode 100644 index 00000000..55b6cecb --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/resp_tool_calls.json @@ -0,0 +1,19 @@ +{ + "id": "chatcmpl-456", + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_abc", + "type": "function", + "function": { + "name": "read_file", + "arguments": "{\"path\":\"/tmp/x.txt\"}" + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 20, "completion_tokens": 8} +}