From d623f4e09f669a1aa196e5c40385e7c6ac6e6992 Mon Sep 17 00:00:00 2001 From: Aliaksandr Tsurko Date: Tue, 1 Sep 2026 21:07:26 +0300 Subject: [PATCH] feat(providers): add Azure OpenAI provider support Add dynamic Azure deployment discovery, classic Chat proxy routing, request sanitization, and compatible client configuration. Keep direct Claude, Codex, and Gemini paths excluded. Include small WSL Docker, Kimi installer, and Pi Windows compatibility fixes. --- .gitleaks.toml | 7 + README.md | 21 +- docs/AGENTS.md | 17 +- docs/COMMANDS.md | 2 +- docs/CONFIGURATION.md | 31 +- scripts/validate-secrets.js | 93 ++++-- src/agents/core/BaseAgentAdapter.ts | 9 +- .../core/__tests__/BaseAgentAdapter.test.ts | 13 +- .../__tests__/codemie-code-reasoning.test.ts | 9 + .../__tests__/claude.provider-support.test.ts | 4 + .../codemie-sdk/examples/integrations.md | 2 +- .../__tests__/shell-hooks-source.test.ts | 7 +- src/agents/plugins/codemie-code.plugin.ts | 96 +++++-- .../codex.plugin.version-support.test.ts | 13 +- .../__tests__/copilot-cli.models.test.ts | 9 + .../plugins/copilot-cli/copilot-cli.models.ts | 9 + .../plugins/copilot-cli/copilot-cli.plugin.ts | 35 ++- .../kimi/__tests__/kimi.models.test.ts | 10 + src/agents/plugins/kimi/kimi.models.ts | 9 + src/agents/plugins/kimi/kimi.plugin.ts | 34 ++- .../opencode/opencode-dynamic-models.ts | 44 ++- .../opencode/opencode-model-configs.ts | 36 ++- .../plugins/opencode/opencode.plugin.ts | 70 +++-- .../plugins/openwiki/openwiki.plugin.ts | 2 +- src/agents/plugins/pi/pi.models.ts | 60 +++- src/agents/plugins/pi/pi.packages.ts | 10 +- src/agents/plugins/pi/pi.plugin.ts | 9 +- src/cli/commands/doctor/README.md | 2 +- .../commands/doctor/checks/AIConfigCheck.ts | 1 + src/cli/commands/setup.ts | 33 ++- src/env/types.ts | 5 + .../core/azure-deployment-catalog.ts | 65 +++++ src/providers/core/registry.ts | 9 + src/providers/index.ts | 7 + src/providers/integration/setup-ui.ts | 43 ++- .../__tests__/azure-openai.template.test.ts | 50 ++++ .../azure-openai/azure-openai.health.ts | 99 +++++++ .../azure-openai/azure-openai.models.ts | 111 +++++++ .../azure-openai/azure-openai.setup-steps.ts | 147 ++++++++++ .../azure-openai/azure-openai.template.ts | 126 ++++++++ src/providers/plugins/azure-openai/index.ts | 17 ++ .../azure-openai-routing.plugin.test.ts | 98 +++++++ .../azure-openai-sanitizer.plugin.test.ts | 271 ++++++++++++++++++ .../plugins/azure-openai-routing.plugin.ts | 215 ++++++++++++++ .../plugins/azure-openai-sanitizer.plugin.ts | 250 ++++++++++++++++ .../plugins/sso/proxy/plugins/index.ts | 6 + src/providers/plugins/sso/proxy/sso.proxy.ts | 4 +- src/utils/config.ts | 39 ++- src/utils/native-installer.ts | 25 +- src/utils/profile.ts | 6 + 50 files changed, 2135 insertions(+), 155 deletions(-) create mode 100644 src/providers/core/azure-deployment-catalog.ts create mode 100644 src/providers/plugins/azure-openai/__tests__/azure-openai.template.test.ts create mode 100644 src/providers/plugins/azure-openai/azure-openai.health.ts create mode 100644 src/providers/plugins/azure-openai/azure-openai.models.ts create mode 100644 src/providers/plugins/azure-openai/azure-openai.setup-steps.ts create mode 100644 src/providers/plugins/azure-openai/azure-openai.template.ts create mode 100644 src/providers/plugins/azure-openai/index.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-routing.plugin.test.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-sanitizer.plugin.test.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/azure-openai-routing.plugin.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/azure-openai-sanitizer.plugin.ts diff --git a/.gitleaks.toml b/.gitleaks.toml index cb1643066..32e676077 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -11,7 +11,14 @@ useDefault = true description = "Exclude test files and build artifacts containing intentional fake secrets" paths = [ '''src/utils/__tests__/sanitize\.test\.ts$''', + '''src/providers/plugins/azure-openai/__tests__/''', '''dist/''', '''\.idea/''', '''\.mcp\.json''' ] +# Ignore well-known no-entropy test placeholder strings that appear in diffs +# when replacing old fixture values. These are not real secrets. +stopwords = [ + "test-api-key-1234567890", + "PLACEHOLDER-KEY-FOR-TESTING-ONLY" +] diff --git a/README.md b/README.md index f76272624..1a077c523 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-5.3%2B-blue.svg)](https://www.typescriptlang.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -> **Unified AI Coding Assistant CLI** - Manage Claude Code, OpenAI Codex, GitHub Copilot CLI, Google Gemini, OpenCode, Pi, Kimi Code, and custom AI agents from one powerful command-line interface. Multi-provider support (CodeMie SSO, Bearer Auth, LiteLLM, AWS Bedrock, Ollama, Anthropic Subscription, Moonshot Subscription). Built-in native agent with file operations, command execution, planning mode, and plugins. Cross-platform support for Windows, Linux, and macOS. +> **Unified AI Coding Assistant CLI** - Manage Claude Code, OpenAI Codex, GitHub Copilot CLI, Google Gemini, OpenCode, Pi, Kimi Code, and custom AI agents from one powerful command-line interface. Multi-provider support (CodeMie SSO, Bearer Auth, LiteLLM, Azure OpenAI, AWS Bedrock, Ollama, Anthropic Subscription, Moonshot Subscription). Built-in native agent with file operations, command execution, planning mode, and plugins. Cross-platform support for Windows, Linux, and macOS. --- @@ -23,7 +23,7 @@ CodeMie CLI is the all-in-one AI coding assistant for developers. - ✨ **One CLI, Multiple AI Agents** - Switch between Claude Code, OpenAI Codex, GitHub Copilot CLI, Gemini, OpenCode, Pi, Kimi Code, and built-in agent. -- 🔄 **Multi-Provider Support** - CodeMie SSO, Bearer Authorization, LiteLLM, AWS Bedrock, Ollama, Anthropic Subscription, and Moonshot Subscription. +- 🔄 **Multi-Provider Support** - CodeMie SSO, Bearer Authorization, LiteLLM, Azure OpenAI, AWS Bedrock, Ollama, Anthropic Subscription, and Moonshot Subscription. - 🚀 **Built-in Agent** - `codemie-code` ships with the CLI: file operations, command execution, planning mode, and native plugins. - 🖥️ **Cross-Platform** - Full support for Windows, Linux, and macOS with platform-specific optimizations. - 🔗 **MCP Proxy** - Connect to remote MCP servers with automatic OAuth authorization. @@ -197,7 +197,7 @@ codemie-code --plugin-dir ./my-plugins # load native plugins codemie-code --debug # debug logging ``` -Providers: CodeMie SSO, Bearer Auth, LiteLLM, AWS Bedrock, Ollama. +Providers: CodeMie SSO, Bearer Auth, LiteLLM, Azure OpenAI, AWS Bedrock, Ollama. ### External Agents @@ -260,7 +260,7 @@ Supported managed path for this release: - **Uninstall:** `codemie uninstall copilot` - **Launch:** `codemie-copilot` - **One-shot task:** `codemie-copilot --task "Explain this service"` -- **Supported providers:** CodeMie SSO and LiteLLM +- **Supported providers:** CodeMie SSO, LiteLLM, and Azure OpenAI (classic Chat Completions) Requirements and behavior: @@ -326,6 +326,7 @@ A profile binds an agent to a provider. Run `codemie setup` to create one, or `c | CodeMie SSO | enterprise SSO | Enterprise default — centralized model management, proxy routing, analytics | | Bearer Authorization | JWT via CLI or env var | CI, service accounts, self-hosted gateways | | LiteLLM | API key | Universal gateway to 100+ LLM providers (OpenAI, Azure, Vertex, …) | +| Azure OpenAI | API key + endpoint | OpenAI-compatible classic Chat Completions clients | | AWS Bedrock | AWS access key + secret | Claude, Llama, Mistral & more via Amazon Bedrock | | Ollama | none | Local open-source models, offline work | | Anthropic Subscription | native Claude Code login | Bring your own Claude subscription | @@ -333,6 +334,16 @@ A profile binds an agent to a provider. Run `codemie setup` to create one, or `c See [Authentication](docs/AUTHENTICATION.md) and [Configuration](docs/CONFIGURATION.md) for setup details. +### Azure for Protocol-Specific Agents + +The `azure-openai` provider is intentionally not supported directly by `codemie-claude`, +`codemie-claude-acp`, `codemie-codex`, or `codemie-gemini`. These agents use different +client protocols: Anthropic Messages, OpenAI Responses, and Gemini's native API. + +To use Azure-backed models with these agents, run LiteLLM as an externally managed gateway. +Configure LiteLLM to expose the protocol expected by the client and route the request to the +Azure deployment. CodeMie stores the LiteLLM URL and key but does not install or run LiteLLM. + ### CodeMie Assistants as Claude Skills or Subagents CodeMie can connect assistants available in your CodeMie account directly into Claude Code. Register them as Claude subagents and call them with `@slug`, or register them as Claude skills and invoke them with `/slug`. @@ -474,7 +485,7 @@ codemie-pi --resume # open a specific session | **Required packages** | `pi-mcp-adapter` (Pi ships without built-in MCP), `pi-subagents`, `superpowers` — installed at setup. | | **Session analytics** | An injected extension records tokens, tools, and models per session and syncs at session end; visible in `codemie analytics`. | -Providers: CodeMie SSO, Bearer Auth, LiteLLM. +Providers: CodeMie SSO, Bearer Auth, LiteLLM, Azure OpenAI (classic Chat Completions). ## Claude Code Statusline diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 00b65b149..64ba91d6e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -51,6 +51,10 @@ Anthropic's official CLI with advanced code understanding. - Interactive conversations - Non-interactive mode with `-p` flag +**Provider compatibility:** +- LiteLLM, AI/Run SSO, AWS Bedrock, Bearer Auth, Anthropic Subscription, and Ollama are supported. +- Direct Azure OpenAI is not supported; use an external LiteLLM gateway for Azure-backed Claude models. + **Usage:** ```bash codemie-claude # Interactive mode @@ -70,7 +74,8 @@ ACP (Agent Communication Protocol) is a stdio-based JSON-RPC protocol that enabl **Requirements:** - Node.js 20.0.0 or higher -- Supported providers: LiteLLM, AI/Run SSO, or direct Anthropic API access +- Supported providers: LiteLLM, AI/Run SSO, AWS Bedrock, Bearer Auth, Anthropic Subscription, or Ollama +- Direct Azure OpenAI is not supported; use an external LiteLLM gateway for Azure-backed models - IDE with ACP support (Zed, JetBrains, Emacs, etc.) **Features:** @@ -133,9 +138,9 @@ Google's Gemini AI coding assistant with advanced code understanding. **Installation:** `codemie install gemini` **Requirements:** -- **Requires a valid Google Gemini API key** from https://aistudio.google.com/apikey +- **Direct API access requires a valid Google Gemini API key** from https://aistudio.google.com/apikey - **Requires Gemini-compatible models only** (gemini-2.5-flash, gemini-2.5-pro, etc.) -- LiteLLM or AI-Run SSO API keys will **not** work with Gemini CLI +- **Direct Azure OpenAI is not supported**; use an external LiteLLM gateway for Azure-backed Gemini models **Setup:** ```bash @@ -174,7 +179,7 @@ GitHub Copilot CLI managed by CodeMie for CodeMie-routed SSO and LiteLLM session **Requirements:** - Node.js 20.0.0 or higher - An authenticated CodeMie profile (`codemie setup`) -- Supported providers: **AI/Run SSO** or **LiteLLM** +- Supported providers: **AI/Run SSO**, **LiteLLM**, or **Azure OpenAI** (classic Chat Completions) **Managed-mode behavior:** - Launch with `codemie-copilot` @@ -214,7 +219,7 @@ Open-source AI coding assistant with comprehensive session analytics. **Requirements:** - Node.js 20.0.0 or higher -- Supported providers: LiteLLM, AI/Run SSO, or direct API access +- Supported providers: LiteLLM, AI/Run SSO, Azure OpenAI (classic Chat Completions), or direct API access - OpenCode CLI installed globally (`opencode-ai` npm package) **Features:** @@ -283,7 +288,7 @@ OpenWiki (https://github.com/langchain-ai/openwiki) — an agent that writes and **Requirements:** - Node.js 22.0.0 or higher (OpenWiki upstream requirement) - An authenticated CodeMie profile (`codemie setup`) -- Supported providers: **AI/Run SSO**, **Bearer Auth (JWT)**, **LiteLLM**, **Ollama**, **Moonshot subscription** +- Supported providers: **AI/Run SSO**, **Bearer Auth (JWT)**, **LiteLLM**, **Ollama**, **Moonshot subscription**, or **Azure OpenAI** (classic Chat Completions) **How CodeMie runs it:** OpenWiki reads its model access from `OPENWIKI_PROVIDER=openai-compatible` plus `OPENAI_COMPATIBLE_BASE_URL`/`OPENAI_COMPATIBLE_API_KEY`/`OPENWIKI_MODEL_ID`. The CodeMie adapter maps the active profile onto those variables: SSO/JWT profiles go through the local CodeMie proxy (authentication and `X-CodeMie-*` attribution headers are injected there), other providers forward their configured base URL and key. The profile model becomes `OPENWIKI_MODEL_ID`. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 3342f1376..a1c3098b3 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -601,7 +601,7 @@ codemie profile refresh # Refresh SSO credentials **Profile List Details:** The `codemie profile` command displays comprehensive information for each profile: - Profile name and active status -- Provider (ai-run-sso, openai, azure, bedrock, litellm, gemini) +- Provider (ai-run-sso, openai, azure-openai, bedrock, litellm, gemini) - Base URL - Model - Timeout settings diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8c42c68ff..f305c2a5d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -101,6 +101,16 @@ Profiles are stored in `~/.codemie/codemie-cli.config.json`: "apiKey": "sk-***", "model": "gpt-4.1", "timeout": 300 + }, + "azure-openai": { + "name": "azure-openai", + "provider": "azure-openai", + "baseUrl": "https://resource.openai.azure.com", + "apiKey": "azure-key-***", + "model": "gpt-5.6-luna-2026-07-09", + "azureApiVersion": "2025-04-01-preview", + "azureDeployment": "gpt-5.6-luna-2026-07-09", + "timeout": 300 } } } @@ -112,11 +122,21 @@ Profiles are stored in `~/.codemie/codemie-cli.config.json`: - **ai-run-sso** - AI/Run CodeMie SSO (unified enterprise gateway) - **openai** - OpenAI API -- **azure** - Azure OpenAI +- **azure-openai** - Azure OpenAI for OpenAI-compatible clients - **bedrock** - AWS Bedrock - **litellm** - LiteLLM Proxy (universal gateway to 100+ providers) - **ollama** - Ollama (local models) +### Azure and Protocol-Specific Agents + +Direct `azure-openai` is intentionally excluded from Claude Code/ACP, Codex, and Gemini +profiles. Their native request formats are Anthropic Messages, OpenAI Responses, and Gemini's +native API, while the CodeMie Azure endpoint exposes classic Azure OpenAI Chat Completions. + +Use an externally managed LiteLLM gateway when these agents need Azure-backed models. The +LiteLLM profile should expose the client-facing protocol and route to the Azure deployment. +CodeMie does not install or manage the LiteLLM process. + ## Manual Configuration ### Environment Variables (Highest Priority) @@ -127,7 +147,7 @@ Environment variables override config file values and are useful for CI/CD, Dock | Variable | Description | Default | Example | |----------|-------------|---------|---------| -| `CODEMIE_PROVIDER` | AI provider (ai-run-sso, litellm, openai, azure, bedrock) | - | `litellm` | +| `CODEMIE_PROVIDER` | AI provider (ai-run-sso, litellm, openai, azure-openai, bedrock) | - | `litellm` | | `CODEMIE_BASE_URL` | Base URL for API endpoint | - | `https://api.openai.com/v1` | | `CODEMIE_API_KEY` | API key for authentication | - | `sk-...` | | `CODEMIE_MODEL` | Model to use | - | `claude-sonnet-4-5-20250929` | @@ -158,8 +178,11 @@ Environment variables override config file values and are useful for CI/CD, Dock | Variable | Description | Default | Example | |----------|-------------|---------|---------| -| `AZURE_OPENAI_API_VERSION` | Azure API version | `2024-02-01` | `2024-02-01` | -| `OPENAI_ORG_ID` | OpenAI organization ID | - | `org-...` | +| `CODEMIE_AZURE_OPENAI_API_VERSION` | Azure API version for deployment discovery and requests | `2025-04-01-preview` | `2025-04-01-preview` | +| `CODEMIE_AZURE_OPENAI_DEPLOYMENT` | Optional Azure deployment override; otherwise `CODEMIE_MODEL` is used | - | `gpt-5.6-luna-2026-07-09` | + +`CODEMIE_AZURE_OPENAI_DEPLOYMENT` overrides `CODEMIE_MODEL` when both are set; both are used as the deployment ID. +`OPENAI_ORG_ID` is not supported for Azure OpenAI. #### Analytics Configuration diff --git a/scripts/validate-secrets.js b/scripts/validate-secrets.js index a5db84de8..360d1b598 100755 --- a/scripts/validate-secrets.js +++ b/scripts/validate-secrets.js @@ -1,9 +1,12 @@ #!/usr/bin/env node /** * Cross-platform secrets detection using Gitleaks - * Works on Windows, macOS, and Linux + * Works on Windows (native Docker or Docker-in-WSL), macOS, and Linux * * Supports Docker, Podman, and Apple Containers. + * On Windows without Docker Desktop, falls back to Docker running inside WSL2 + * by invoking: wsl -e bash -l -c "docker ..." + * * CI uses the official gitleaks-action@v2 for better GitHub integration. * Both share the same .gitleaks.toml configuration. * @@ -47,11 +50,32 @@ function appleContainersRunning() { return spawnSync(bin, ['system', 'status'], { stdio: 'ignore', shell: false }).status === 0; } +/** + * On Windows, check if Docker is available inside WSL2 by running + * `wsl -e bash -l -c "docker info"`. Returns true if the daemon responds. + */ +function wslDockerRunning() { + if (!isWindows) return false; + const wslBin = resolveCommand('wsl'); + if (!wslBin) return false; + const result = spawnSync(wslBin, ['-e', 'bash', '-l', '-c', 'docker info'], { + stdio: 'ignore', + shell: false, + }); + return result.status === 0; +} + +/** + * Detects the available container engine. + * Returns one of: 'docker' | 'podman' | 'container' | 'wsl-docker' | null + */ function detectEngine() { for (const engine of ['docker', 'podman']) { if (commandExists(engine) && daemonRunning(engine)) return engine; } if (appleContainersRunning()) return 'container'; + // Fallback: Docker running inside WSL2 on Windows + if (wslDockerRunning()) return 'wsl-docker'; return null; } @@ -67,7 +91,7 @@ if (!engine) { process.exit(0); } -const engineBin = resolveCommand(engine); +const engineBin = engine === 'wsl-docker' ? resolveCommand('wsl') : resolveCommand(engine); if (!engineBin) { if (process.env.CODEMIE_SKIP_SECRETS_SCAN !== '1') { console.error('Container engine binary not found — install Docker, Podman, or Apple Containers to enable local secrets scanning.'); @@ -80,7 +104,6 @@ if (!engineBin) { // shell:true is used on Windows so paths with spaces must be quoted for the shell. // On Linux/Mac shell:false passes the path directly to execve — no quoting needed. const spawnBin = isWindows && engineBin.includes(' ') ? `"${engineBin}"` : engineBin; - // Produce the staged diff on the host so gitleaks doesn't need git access // inside the container — required for Apple Containers which cannot run git // against the host .git index through a bind mount. @@ -97,24 +120,62 @@ if (!stagedDiff || stagedDiff.length === 0) { process.exit(0); } -const args = ['run', '--rm', '-i']; +console.log(`Running Gitleaks secrets detection (engine: ${engine})...`); -if (hasConfig) { - args.push('-v', `${projectPath}/.gitleaks.toml:/gitleaks.toml`); -} +let gitleaks; -args.push('ghcr.io/gitleaks/gitleaks:v8.30.1', 'detect', '--pipe', '--verbose'); +if (engine === 'wsl-docker') { + // Docker is inside WSL2: build the full docker command as a shell string + // and pass it via `wsl -e bash -l -c "..."`. + // The .gitleaks.toml is mounted from the WSL-translated Windows path. + const wslBin = resolveCommand('wsl'); -if (hasConfig) { - args.push('--config=/gitleaks.toml'); -} + // Convert Windows path to WSL /mnt/... path: C:\foo\bar -> /mnt/c/foo/bar + function toWslPath(winPath) { + return winPath.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); + } -console.log('Running Gitleaks secrets detection...'); + const wslProjectPath = toWslPath(projectPath); + const wslConfigPath = toWslPath(configPath); -const gitleaks = spawn(spawnBin, args, { - stdio: ['pipe', 'inherit', 'inherit'], - shell: isWindows, -}); + let dockerCmd = 'docker run --rm -i'; + if (hasConfig) { + dockerCmd += ` -v "${wslConfigPath}:/gitleaks.toml"`; + } + dockerCmd += ' ghcr.io/gitleaks/gitleaks:v8.30.1 detect --pipe --verbose'; + if (hasConfig) { + dockerCmd += ' --config=/gitleaks.toml'; + } + + // Pipe the staged diff into the WSL command via stdin + gitleaks = spawn(wslBin, ['-e', 'bash', '-l', '-c', dockerCmd], { + stdio: ['pipe', 'inherit', 'inherit'], + shell: false, + }); +} else { + const engineBin = resolveCommand(engine); + if (!engineBin) { + console.log('Container engine binary not found — skipping secrets detection'); + process.exit(1); + } + // shell:true is used on Windows so paths with spaces must be quoted for the shell. + // On Linux/Mac shell:false passes the path directly to execve — no quoting needed. + const spawnBin = isWindows && engineBin.includes(' ') ? `"${engineBin}"` : engineBin; + + const args = ['run', '--rm', '-i']; + if (hasConfig) { + args.push('-v', `${projectPath}/.gitleaks.toml:/gitleaks.toml`); + } + args.push('ghcr.io/gitleaks/gitleaks:v8.30.1', 'detect', '--pipe', '--verbose'); + if (hasConfig) { + args.push('--config=/gitleaks.toml'); + } + + gitleaks = spawn(spawnBin, args, { + stdio: ['pipe', 'inherit', 'inherit'], + shell: isWindows, + }); +} gitleaks.stdin.write(stagedDiff); gitleaks.stdin.end(); diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index d67d720a2..89e7419c1 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -567,6 +567,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // Display ASCII logo with configuration console.log( renderProfileInfo({ + title: 'Profile', profile: profileName, provider, model, @@ -952,11 +953,12 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const isSSOProvider = provider?.authType === 'sso'; const isJWTAuth = env.CODEMIE_AUTH_METHOD === 'jwt'; + const isAzureOpenAIProvider = providerName === 'azure-openai'; const isProxyEnabled = this.metadata.ssoConfig?.enabled ?? false; // Proxy is only for model API authentication/forwarding. Analytics sync can // be configured independently and must not force native providers through it. - return (isSSOProvider || isJWTAuth) && isProxyEnabled; + return (isSSOProvider || isJWTAuth || isAzureOpenAIProvider) && isProxyEnabled; } /** @@ -1033,7 +1035,10 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // Update environment with proxy URL env.CODEMIE_BASE_URL = url; - env.CODEMIE_API_KEY = 'proxy-handled'; + env.CODEMIE_PROXY_ACTIVE = '1'; + if (env.CODEMIE_PROVIDER !== 'azure-openai') { + env.CODEMIE_API_KEY = 'proxy-handled'; + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Proxy setup failed: ${errorMessage}`); diff --git a/src/agents/core/__tests__/BaseAgentAdapter.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.test.ts index 0ad24917c..f116b0467 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.test.ts @@ -18,12 +18,13 @@ vi.mock('../../../providers/core/registry.js', () => { }; return { ProviderRegistry: { - registerProvider: vi.fn((t: any) => t), - registerSetupSteps: vi.fn(), - registerHealthCheck: vi.fn(), - registerModelProxy: vi.fn(), - getProvider: vi.fn((name: string) => providers[name]), - getProviderNames: vi.fn(() => Object.keys(providers)), + registerProvider: vi.fn((t: any) => t), + registerSetupSteps: vi.fn(), + registerHealthCheck: vi.fn(), + registerModelProxy: vi.fn(), + registerProviderSetup: vi.fn((t: any) => t), + getProvider: vi.fn((name: string) => providers[name]), + getProviderNames: vi.fn(() => Object.keys(providers)), }, }; }); diff --git a/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts b/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts index f64c30ff3..7d59f53d0 100644 --- a/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts +++ b/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts @@ -107,6 +107,14 @@ vi.mock('../opencode/opencode-dynamic-models.js', () => ({ fetchDynamicModelConfigs: vi.fn(() => Promise.resolve({})), })); +// Mock AzureOpenAIModelProxy so azure-openai provider path doesn't make real requests +vi.mock('../../../providers/plugins/azure-openai/azure-openai.models.js', () => ({ + AzureOpenAIModelProxy: vi.fn().mockImplementation(() => ({ + fetchDeploymentInfos: vi.fn(() => Promise.resolve([])), + fetchModels: vi.fn(() => Promise.resolve([])), + })), +})); + // Mock fs vi.mock('fs', () => ({ existsSync: vi.fn(() => true), @@ -242,6 +250,7 @@ describe('CodeMie Code Plugin — Reasoning Sanitization Integration', () => { expect(config.plugin).toContain('file:///mock/hooks-plugin.js'); expect(config.plugin).toContain('file:///mock/reasoning-sanitizer.ts'); }); + }); describe('Cleanup — onSessionEnd', () => { diff --git a/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts b/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts index 1f6577bc9..88a4affad 100644 --- a/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts @@ -5,4 +5,8 @@ describe('ClaudePluginMetadata', () => { it('supports anthropic-subscription provider', () => { expect(ClaudePluginMetadata.supportedProviders).toContain('anthropic-subscription'); }); + + it('does not support direct azure-openai provider', () => { + expect(ClaudePluginMetadata.supportedProviders).not.toContain('azure-openai'); + }); }); diff --git a/src/agents/plugins/claude/plugin/skills/codemie-sdk/examples/integrations.md b/src/agents/plugins/claude/plugin/skills/codemie-sdk/examples/integrations.md index 68c56bad0..4eaf44daf 100644 --- a/src/agents/plugins/claude/plugin/skills/codemie-sdk/examples/integrations.md +++ b/src/agents/plugins/claude/plugin/skills/codemie-sdk/examples/integrations.md @@ -69,7 +69,7 @@ codemie sdk integrations create --json jira-integration.json | `enabled` | — | `false` = disable the integration without deleting it (default: `true`) | | `external_id` | — | External system identifier for cross-referencing with other tools | -**All supported `credential_type` values:** `Jira`, `Confluence`, `Git`, `Kubernetes`, `AWS`, `GCP`, `Azure`, `Keycloak`, `Elastic`, `OpenAPI`, `Plugin`, `FileSystem`, `Scheduler`, `Webhook`, `Email`, `AzureDevOps`, `Sonar`, `SQL`, `Telegram`, `ZephyrScale`, `ZephyrSquad`, `ServiceNow`, `DIAL`, `A2A`, `MCP`, `LiteLLM`, `ReportPortal`, `Xray`, `SharePoint` +**All supported `credential_type` values:** `Jira`, `Confluence`, `Git`, `Kubernetes`, `AWS`, `GCP`, `Azure`, `Keycloak`, `Elastic`, `OpenAPI`, `Plugin`, `FileSystem`, `Scheduler`, `Webhook`, `Email`, `AzureDevOps`, `Sonar`, `SQL`, `Telegram`, `ZephyrScale`, `ZephyrSquad`, `ServiceNow`, `Azure OpenAI`, `A2A`, `MCP`, `LiteLLM`, `ReportPortal`, `Xray`, `SharePoint` > **Important:** `credential_values` **must include an `alias` key** with the same value as the top-level `alias` field, otherwise the API returns an error. Always add `{"key": "alias", "value": ""}` to the array. diff --git a/src/agents/plugins/codemie-code-hooks/__tests__/shell-hooks-source.test.ts b/src/agents/plugins/codemie-code-hooks/__tests__/shell-hooks-source.test.ts index 31b34a81a..e3591b74f 100644 --- a/src/agents/plugins/codemie-code-hooks/__tests__/shell-hooks-source.test.ts +++ b/src/agents/plugins/codemie-code-hooks/__tests__/shell-hooks-source.test.ts @@ -42,10 +42,15 @@ let modulePath: string; /** Load the plugin factory the way the OpenCode runtime would. */ async function loadHooks(hookNames: string[]): Promise> { const hooks: Record = {}; + // Use PowerShell on Windows because execSync invokes cmd.exe, which lacks cat. + const captureCommand = process.platform === 'win32' + ? `powershell.exe -NoProfile -NonInteractive -Command "$data = [Console]::In.ReadToEnd(); [IO.File]::AppendAllText('${capturePath.replace(/'/g, "''")}', $data)"` + : `cat >> ${capturePath}`; + for (const name of hookNames) { // A shell command that appends whatever arrives on stdin, standing in for // the real `codemie hook` binary. - hooks[name] = [{ hooks: [{ type: 'command', command: `cat >> ${capturePath}` }] }]; + hooks[name] = [{ hooks: [{ type: 'command', command: captureCommand }] }]; } process.env.OPENCODE_HOOKS = JSON.stringify({ hooks }); diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index e08b830c0..4eda5c72c 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -3,7 +3,11 @@ import { join } from 'path'; import { existsSync } from 'fs'; import { logger } from '../../utils/logger.js'; import { getModelConfig, getChatCompletionsModelConfigs, getResponsesApiModelConfigs } from './opencode/opencode-model-configs.js'; -import { fetchDynamicModelConfigs } from './opencode/opencode-dynamic-models.js'; +import { + fetchAzureOpenCodeModelConfigs, + fetchDynamicModelConfigs, +} from './opencode/opencode-dynamic-models.js'; +import { getAzureConnectionConfig } from '../../providers/core/azure-deployment-catalog.js'; import { BaseAgentAdapter } from '../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../core/extension/BaseExtensionInstaller.js'; @@ -86,14 +90,14 @@ function resolveOllamaBaseUrl(baseUrl: string, provider: string | undefined): st /** * Build the OpenCode config object that gets passed to the whitelabel binary. * - * Models are split into two groups: - * - chatModels: routed via codemie-proxy/litellm (Chat Completions API) - * - responsesApiModels: routed via OpenCode's built-in openai CUSTOM_LOADER (Responses API) + * All providers use a stable provider entry; Azure deployment routing is + * resolved by the CodeMie proxy from the request model. */ function buildOpenCodeConfig(params: { proxyBaseUrl: string | undefined; litellmBaseUrl: string | undefined; litellmApiKey: string | undefined; + azureOpenAI: boolean; ollamaBaseUrl: string; activeProvider: string; modelId: string; @@ -104,11 +108,22 @@ function buildOpenCodeConfig(params: { responsesApiBaseUrl: string | undefined; }): Record { const hasResponsesApiModels = Object.keys(params.responsesApiModels).length > 0; + const baseEnabledProviders = ['codemie-proxy', 'openai', 'ollama', 'amazon-bedrock', 'litellm']; + const modelProvider = params.azureOpenAI + ? (Object.prototype.hasOwnProperty.call(params.responsesApiModels, params.modelId) + ? 'openai' + : 'azure-openai') + : params.activeProvider; + const enabledProviders = [...new Set([ + ...baseEnabledProviders, + modelProvider, + ...(params.azureOpenAI ? ['azure-openai'] : []), + ])]; return { - enabled_providers: ['codemie-proxy', 'openai', 'ollama', 'amazon-bedrock', 'litellm'], + enabled_providers: enabledProviders, share: 'disabled', provider: { - ...(params.proxyBaseUrl && { + ...(params.proxyBaseUrl && !params.azureOpenAI && { 'codemie-proxy': { npm: '@ai-sdk/openai-compatible', name: 'CodeMie SSO', @@ -121,13 +136,22 @@ function buildOpenCodeConfig(params: { models: params.chatModels } }), - // OpenCode's built-in openai CUSTOM_LOADER — uses @ai-sdk/openai sdk.responses() - // which calls POST /v1/responses instead of /v1/chat/completions + ...(params.azureOpenAI && params.proxyBaseUrl && { + 'azure-openai': { + npm: '@ai-sdk/openai-compatible', + name: 'Azure OpenAI', + options: { + baseURL: `${params.proxyBaseUrl}/`, + apiKey: 'proxy-handled', + timeout: params.timeout, + ...(params.providerOptions?.headers && { headers: params.providerOptions.headers }) + }, + models: params.chatModels + } + }), ...(params.responsesApiBaseUrl && hasResponsesApiModels && { openai: { name: 'CodeMie SSO', - // whitelist: suppress the built-in openai model list (GPT-4, GPT-4o, etc.) - // OpenCode merges user models with models.dev — whitelist restricts to ours only whitelist: Object.keys(params.responsesApiModels), options: { baseURL: `${params.responsesApiBaseUrl}/`, @@ -160,7 +184,7 @@ function buildOpenCodeConfig(params: { } } }, - model: `${params.activeProvider}/${params.modelId}` + model: `${modelProvider}/${params.modelId}` }; } @@ -219,7 +243,7 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { model: [] }, - supportedProviders: ['litellm', 'ai-run-sso', 'ollama', 'bedrock', 'bearer-auth'], + supportedProviders: ['litellm', 'ai-run-sso', 'ollama', 'bedrock', 'bearer-auth', 'azure-openai'], ssoConfig: { enabled: true, clientType: 'codemie-code' }, @@ -248,9 +272,20 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { if (sessionId) { // ensureSessionFile handles its own errors internally await ensureSessionFile(sessionId, env, BUILTIN_AGENT_NAME); + } + // Resolve the effective provider name. + // CODEMIE_PROVIDER is set by ConfigLoader.exportProviderEnvVars from config.provider. const provider = env.CODEMIE_PROVIDER; + const isAzureOpenAI = provider === 'azure-openai'; + + // Do not let Azure-only sanitizer flags leak into another provider when + // the parent shell still contains variables from an earlier Azure run. + if (!isAzureOpenAI) { + delete env.CLAUDE_CODE_USE_AZURE_OPENAI; + } + const baseUrl = env.CODEMIE_BASE_URL; if (!baseUrl) { @@ -262,17 +297,14 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { return env; } - // Fetch live model catalogue from the CodeMie API. - // Falls back to the static OPENCODE_MODEL_CONFIGS on any error. - const allModels = await fetchDynamicModelConfigs( - baseUrl, - env.CODEMIE_URL, - env.CODEMIE_JWT_TOKEN, - ); - - // Model selection priority: env var > config > default - // Use dynamic catalogue first, then fall back to static getModelConfig for unknown IDs. - const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; + const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-4.1'; + const allModels = isAzureOpenAI + ? await fetchAzureOpenCodeModelConfigs(getAzureConnectionConfig(env), selectedModel) + : await fetchDynamicModelConfigs( + baseUrl, + env.CODEMIE_URL, + env.CODEMIE_JWT_TOKEN, + ); const modelConfig = allModels[selectedModel] ?? getModelConfig(selectedModel); const { providerOptions } = modelConfig; const chatModels = getChatCompletionsModelConfigs(allModels); @@ -282,15 +314,14 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { const isLiteLLM = provider === 'litellm'; const proxyBaseUrl = provider !== 'ollama' && !isBedrock && !isLiteLLM ? baseUrl : undefined; const ollamaBaseUrl = resolveOllamaBaseUrl(baseUrl, provider); - const activeProvider = determineActiveProvider(provider); const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; const modelId = isBedrock ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : modelConfig.id; + const activeProvider = isAzureOpenAI + ? (Object.prototype.hasOwnProperty.call(responsesApiModels, modelId) ? 'openai' : 'azure-openai') + : determineActiveProvider(provider); - // Responses API base URL: use proxyBaseUrl for SSO/bearer-auth, or baseUrl for LiteLLM. - // Always set regardless of selected model — fixes model-switching bug where switching - // from a Claude model to a GPT model mid-session would miss the CUSTOM_LOADER. const responsesApiBaseUrl = proxyBaseUrl || (isLiteLLM ? baseUrl : undefined); if (responsesApiBaseUrl && Object.keys(responsesApiModels).length > 0) { env.OPENAI_API_KEY = 'proxy-handled'; @@ -301,8 +332,15 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { proxyBaseUrl, litellmBaseUrl: isLiteLLM ? baseUrl : undefined, litellmApiKey: isLiteLLM ? env.CODEMIE_API_KEY : undefined, - ollamaBaseUrl, activeProvider, modelId, timeout, providerOptions, - chatModels, responsesApiModels, responsesApiBaseUrl + azureOpenAI: isAzureOpenAI, + ollamaBaseUrl, + activeProvider, + modelId, + timeout, + providerOptions, + chatModels, + responsesApiModels, + responsesApiBaseUrl, }); // --- Hooks injection --- diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index 4c4754585..18098fccc 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../../providers/core/registry.js', () => ({ ProviderRegistry: { - registerProvider: vi.fn((template: unknown) => template), - registerSetupSteps: vi.fn(), - registerHealthCheck: vi.fn(), - registerModelProxy: vi.fn(), - getProvider: vi.fn(), - getProviderNames: vi.fn(() => []), + registerProvider: vi.fn((template: unknown) => template), + registerSetupSteps: vi.fn(), + registerHealthCheck: vi.fn(), + registerModelProxy: vi.fn(), + registerProviderSetup: vi.fn((template: unknown) => template), + getProvider: vi.fn(), + getProviderNames: vi.fn(() => []), }, })); diff --git a/src/agents/plugins/copilot-cli/__tests__/copilot-cli.models.test.ts b/src/agents/plugins/copilot-cli/__tests__/copilot-cli.models.test.ts index 45a916833..be03d1b55 100644 --- a/src/agents/plugins/copilot-cli/__tests__/copilot-cli.models.test.ts +++ b/src/agents/plugins/copilot-cli/__tests__/copilot-cli.models.test.ts @@ -72,4 +72,13 @@ describe('copilot-cli model resolution', () => { expect(() => assertExplicitCopilotModelAllowed('o4-mini', ['gpt-5.5', 'claude-sonnet-4.6'])) .toThrow(/GPT-family or Claude-family model/); }); + + it('allows arbitrary Azure deployment IDs when discovery returns them', async () => { + const { assertExplicitCopilotDeploymentAllowed } = await import('../copilot-cli.models.js'); + + expect(() => assertExplicitCopilotDeploymentAllowed('production-chat', ['production-chat'])) + .not.toThrow(); + expect(() => assertExplicitCopilotDeploymentAllowed('missing-chat', ['production-chat'])) + .toThrow(/not available/); + }); }); diff --git a/src/agents/plugins/copilot-cli/copilot-cli.models.ts b/src/agents/plugins/copilot-cli/copilot-cli.models.ts index 2f25ae4a0..e5d5dfd0d 100644 --- a/src/agents/plugins/copilot-cli/copilot-cli.models.ts +++ b/src/agents/plugins/copilot-cli/copilot-cli.models.ts @@ -242,3 +242,12 @@ export function assertExplicitCopilotModelAllowed(model: string, availableModels ); } } + +export function assertExplicitCopilotDeploymentAllowed(model: string, availableModels: string[]): void { + if (availableModels.length > 0 && !availableModels.includes(model)) { + throw new ConfigurationError( + `Azure OpenAI deployment "${model}" is not available for codemie-copilot. ` + + `Available deployments: ${availableModels.join(', ')}` + ); + } +} diff --git a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts index 77ea4a4c5..5fb710881 100644 --- a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts +++ b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts @@ -11,9 +11,14 @@ import { COPILOT_CLI_DISPLAY_NAME, } from './copilot-cli.constants.js'; import { + assertExplicitCopilotDeploymentAllowed, assertExplicitCopilotModelAllowed, resolveCopilotModel, } from './copilot-cli.models.js'; +import { + fetchAzureDeploymentModels, + getAzureConnectionConfig, +} from '../../../providers/core/azure-deployment-catalog.js'; export { COPILOT_CLI_AGENT_NAME, @@ -23,7 +28,7 @@ export { const COPILOT_SUPPORTED_VERSION = '1.0.79'; const COPILOT_MINIMUM_SUPPORTED_VERSION = '1.0.70'; -const COPILOT_COMPATIBLE_PROVIDERS = ['ai-run-sso', 'litellm'] as const; +const COPILOT_COMPATIBLE_PROVIDERS = ['ai-run-sso', 'litellm', 'azure-openai'] as const; const COPILOT_RECOMMENDED_MODELS = ['gpt-5.5', 'claude-sonnet-4.6', 'gpt-5.4']; function buildCopilotHookConfig(env: NodeJS.ProcessEnv, sessionId: string) { @@ -55,7 +60,9 @@ function buildCopilotProviderEnv(env: NodeJS.ProcessEnv): Record ); } - const providerType = /^claude/i.test(env.CODEMIE_MODEL) ? 'anthropic' : 'openai'; + const providerType = env.CODEMIE_PROVIDER === 'azure-openai' + ? 'openai' + : (/^claude/i.test(env.CODEMIE_MODEL) ? 'anthropic' : 'openai'); const providerEnv: Record = { COPILOT_PROVIDER_BASE_URL: env.CODEMIE_BASE_URL, COPILOT_PROVIDER_TYPE: providerType, @@ -65,8 +72,10 @@ function buildCopilotProviderEnv(env: NodeJS.ProcessEnv): Record COPILOT_OFFLINE: 'true', }; - if (/^gpt[-_.]?5/i.test(env.CODEMIE_MODEL)) { + if (env.CODEMIE_PROVIDER !== 'azure-openai' && /^gpt[-_.]?5/i.test(env.CODEMIE_MODEL)) { providerEnv.COPILOT_PROVIDER_WIRE_API = 'responses'; + } else if (env.CODEMIE_PROVIDER === 'azure-openai') { + providerEnv.COPILOT_PROVIDER_WIRE_API = 'completions'; } if (env.CODEMIE_API_KEY) { @@ -81,7 +90,7 @@ function assertCopilotProviderSupported(config: AgentConfig): void { const provider = config.provider; if (!provider || !COPILOT_COMPATIBLE_PROVIDERS.includes(provider as (typeof COPILOT_COMPATIBLE_PROVIDERS)[number])) { throw new ConfigurationError( - 'GitHub Copilot CLI via CodeMie currently supports only AI/Run SSO and LiteLLM profiles. ' + + 'GitHub Copilot CLI via CodeMie currently supports AI/Run SSO, LiteLLM, and Azure OpenAI profiles. ' + 'Run codemie setup to choose a supported provider.' ); } @@ -187,7 +196,11 @@ export const CopilotCliPluginMetadata: AgentMetadata = { .map((entry) => entry.trim()) .filter(Boolean); if (env.CODEMIE_MODEL) { - assertExplicitCopilotModelAllowed(env.CODEMIE_MODEL, availableModels); + if (env.CODEMIE_PROVIDER === 'azure-openai') { + assertExplicitCopilotDeploymentAllowed(env.CODEMIE_MODEL, availableModels); + } else { + assertExplicitCopilotModelAllowed(env.CODEMIE_MODEL, availableModels); + } } const providerEnv = buildCopilotProviderEnv(env); @@ -275,6 +288,18 @@ export class CopilotCliPlugin extends BaseAgentAdapter { } protected override async setupProxy(env: NodeJS.ProcessEnv): Promise { + if (env.CODEMIE_PROVIDER === 'azure-openai') { + if (env.CODEMIE_MODEL) { + const models = await fetchAzureDeploymentModels( + getAzureConnectionConfig(env), + env.CODEMIE_MODEL, + ); + env.CODEMIE_COPILOT_AVAILABLE_MODELS = models.map(model => model.id).join(','); + } + await super.setupProxy(env); + return; + } + if (env.CODEMIE_PROVIDER !== 'ai-run-sso' && env.CODEMIE_AUTH_METHOD !== 'jwt') { await super.setupProxy(env); return; diff --git a/src/agents/plugins/kimi/__tests__/kimi.models.test.ts b/src/agents/plugins/kimi/__tests__/kimi.models.test.ts index 6e213fa90..ba4bb2353 100644 --- a/src/agents/plugins/kimi/__tests__/kimi.models.test.ts +++ b/src/agents/plugins/kimi/__tests__/kimi.models.test.ts @@ -20,6 +20,7 @@ vi.mock('../../../../providers/plugins/sso/sso.http-client.js', async (importOri }); import { + assertExplicitKimiDeploymentAllowed, isKimiCompatibleModelName, resolveKimiModel, assertExplicitKimiModelAllowed, @@ -198,3 +199,12 @@ describe('assertExplicitKimiModelAllowed', () => { expect(() => assertExplicitKimiModelAllowed('kimi-k2', [])).not.toThrow(); }); }); + +describe('assertExplicitKimiDeploymentAllowed', () => { + it('allows arbitrary Azure deployment IDs when discovery returns them', () => { + expect(() => assertExplicitKimiDeploymentAllowed('production-chat', ['production-chat'])) + .not.toThrow(); + expect(() => assertExplicitKimiDeploymentAllowed('missing-chat', ['production-chat'])) + .toThrow(/not available/); + }); +}); diff --git a/src/agents/plugins/kimi/kimi.models.ts b/src/agents/plugins/kimi/kimi.models.ts index b2a49dea4..a99e570a1 100644 --- a/src/agents/plugins/kimi/kimi.models.ts +++ b/src/agents/plugins/kimi/kimi.models.ts @@ -218,3 +218,12 @@ export function assertExplicitKimiModelAllowed(model: string, availableModels: s ); } } + +export function assertExplicitKimiDeploymentAllowed(model: string, availableModels: string[]): void { + if (availableModels.length > 0 && !availableModels.includes(model)) { + throw new ConfigurationError( + `Azure OpenAI deployment "${model}" is not available for codemie-kimi. ` + + `Available deployments: ${availableModels.join(', ')}` + ); + } +} diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index dc24ce2ce..f3c980fda 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -7,7 +7,11 @@ import { rm } from 'fs/promises'; import { KimiSessionAdapter } from './kimi.session.js'; import { KimiExtensionInstaller } from './kimi.extension-installer.js'; import { KimiHookTransformer } from './kimi.hook-transformer.js'; -import { assertExplicitKimiModelAllowed, resolveKimiModel } from './kimi.models.js'; +import { + assertExplicitKimiDeploymentAllowed, + assertExplicitKimiModelAllowed, + resolveKimiModel, +} from './kimi.models.js'; import { installNativeAgent } from '../../../utils/native-installer.js'; import { AgentInstallationError, @@ -19,6 +23,10 @@ import { logger } from '../../../utils/logger.js'; import { sanitizeLogArgs } from '../../../utils/security.js'; import { commandExists, exec, getCommandPath } from '../../../utils/processes.js'; import { resolveHomeDir } from '../../../utils/paths.js'; +import { + fetchAzureDeploymentModels, + getAzureConnectionConfig, +} from '../../../providers/core/azure-deployment-catalog.js'; const KIMI_SUPPORTED_VERSION = '0.16.0'; const KIMI_MINIMUM_SUPPORTED_VERSION = '0.15.0'; @@ -48,7 +56,7 @@ export const KimiPluginMetadata: AgentMetadata = { apiKey: ['KIMI_MODEL_API_KEY'], model: ['KIMI_MODEL_NAME'], }, - supportedProviders: ['moonshot-subscription', 'ai-run-sso'], + supportedProviders: ['moonshot-subscription', 'ai-run-sso', 'azure-openai'], blockedModelPatterns: [], recommendedModels: ['kimi-k2.6', 'kimi-for-coding', 'kimi-k2'], ssoConfig: { enabled: true, clientType: 'codemie-kimi' }, @@ -80,7 +88,11 @@ export const KimiPluginMetadata: AgentMetadata = { .map(model => model.trim()) .filter(Boolean); - assertExplicitKimiModelAllowed(explicitModel, availableModels); + if (process.env.CODEMIE_PROVIDER === 'azure-openai') { + assertExplicitKimiDeploymentAllowed(explicitModel, availableModels); + } else { + assertExplicitKimiModelAllowed(explicitModel, availableModels); + } return args; }, }, @@ -190,7 +202,9 @@ export class KimiPlugin extends BaseAgentAdapter { timeout: 300000, verifyCommand: this.metadata.cliCommand || undefined, verifyPath: - process.platform === 'win32' ? undefined : resolveHomeDir(KIMI_NATIVE_BINARY_PATH), + process.platform === 'win32' + ? resolveHomeDir(`${KIMI_NATIVE_BINARY_PATH}.exe`) + : resolveHomeDir(KIMI_NATIVE_BINARY_PATH), }, ); @@ -370,6 +384,18 @@ export class KimiPlugin extends BaseAgentAdapter { } protected override async setupProxy(env: NodeJS.ProcessEnv): Promise { + if (env.CODEMIE_PROVIDER === 'azure-openai') { + if (env.CODEMIE_MODEL) { + const models = await fetchAzureDeploymentModels( + getAzureConnectionConfig(env), + env.CODEMIE_MODEL, + ); + env.CODEMIE_KIMI_AVAILABLE_MODELS = models.map(model => model.id).join(','); + } + await super.setupProxy(env); + return; + } + if (env.CODEMIE_PROVIDER === 'ai-run-sso' || env.CODEMIE_AUTH_METHOD === 'jwt') { // Resolve before BaseAgentAdapter builds the proxy config because CODEMIE_MODEL // is part of the proxy startup contract. diff --git a/src/agents/plugins/opencode/opencode-dynamic-models.ts b/src/agents/plugins/opencode/opencode-dynamic-models.ts index 528a82346..85683416f 100644 --- a/src/agents/plugins/opencode/opencode-dynamic-models.ts +++ b/src/agents/plugins/opencode/opencode-dynamic-models.ts @@ -16,9 +16,10 @@ import type { LlmModel } from '../../../providers/plugins/sso/sso.http-client.js'; import { fetchCodeMieLlmModels } from '../../../providers/plugins/sso/sso.http-client.js'; import type { OpenCodeModelConfig } from './opencode-model-configs.js'; -import { OPENCODE_MODEL_CONFIGS } from './opencode-model-configs.js'; +import { getModelConfig, OPENCODE_MODEL_CONFIGS } from './opencode-model-configs.js'; import { CodeMieSSO } from '../../../providers/plugins/sso/sso.auth.js'; import { logger } from '../../../utils/logger.js'; +import { fetchAzureDeploymentModels, type AzureConnectionConfig } from '../../../providers/core/azure-deployment-catalog.js'; // ── Responses-API detection ────────────────────────────────────────────────── // @@ -55,15 +56,19 @@ function isResponsesApiModel(id: string): boolean { // ── Family detection ───────────────────────────────────────────────────────── function detectFamily(id: string): string { - if (id.startsWith('claude')) return 'claude-4'; - if (id.startsWith('gemini')) return 'gemini-2'; - if (id.startsWith('gpt-4')) return 'gpt-4'; - if (id.startsWith('gpt-5')) return 'gpt-5'; - if (/^o[134]-/.test(id) || id === 'o1') return 'openai-reasoning'; - if (id.startsWith('qwen')) return 'qwen3'; - if (id.startsWith('deepseek')) return 'deepseek'; + // Support vendor-prefixed model names (e.g. "anthropic.claude-...", "meta.llama-..."). + const bare = id.includes('.') ? id.split('.').slice(1).join('.') : id; + if (bare.startsWith('claude') || id.startsWith('claude')) return 'claude-4'; + if (bare.startsWith('gemini') || id.startsWith('gemini')) return 'gemini-2'; + if (bare.startsWith('gpt-4') || id.startsWith('gpt-4')) return 'gpt-4'; + if (bare.startsWith('gpt-5') || id.startsWith('gpt-5')) return 'gpt-5'; + if (/^o[134]-/.test(bare) || bare === 'o1' || /^o[134]-/.test(id) || id === 'o1') return 'openai-reasoning'; + if (bare.startsWith('qwen') || id.startsWith('qwen')) return 'qwen3'; + if (bare.startsWith('deepseek') || id.startsWith('deepseek')) return 'deepseek'; + if (bare.startsWith('llama') || id.startsWith('llama') || id.startsWith('meta.llama')) return 'llama'; + if (bare.startsWith('mistral') || id.startsWith('mistral')) return 'mistral'; if (id.startsWith('moonshotai') || id.startsWith('kimi')) return 'kimi'; - return id.split('-')[0] || id; + return id.split('.').pop()?.split('-')[0] || id.split('-')[0] || id; } // ── Token-limit heuristics ─────────────────────────────────────────────────── @@ -72,7 +77,7 @@ function detectFamily(id: string): string { // We derive reasonable defaults from the model family. function detectLimits(id: string, family: string): { context: number; output: number } { - if (family === 'claude-4' || id.startsWith('claude')) return { context: 200000, output: 64000 }; + if (family === 'claude-4' || id.startsWith('claude') || id.includes('.claude')) return { context: 200000, output: 64000 }; if (family === 'gemini-2' || id.startsWith('gemini')) return { context: 1048576, output: 65536 }; if (id.startsWith('gpt-4.1')) return { context: 1048576, output: 32768 }; if (id.startsWith('gpt-4o')) return { context: 128000, output: 16384 }; @@ -194,3 +199,22 @@ export async function fetchDynamicModelConfigs( return OPENCODE_MODEL_CONFIGS; } } + +export async function fetchAzureOpenCodeModelConfigs( + connection: AzureConnectionConfig, + selectedModel: string, +): Promise> { + const deployments = await fetchAzureDeploymentModels(connection, selectedModel); + return Object.fromEntries(deployments.map(deployment => { + const modelHint = typeof deployment.metadata?.model === 'string' + ? deployment.metadata.model + : deployment.id; + const { use_responses_api: _responses, ...base } = getModelConfig(modelHint); + return [deployment.id, { + ...base, + id: deployment.id, + name: deployment.name || deployment.id, + displayName: deployment.name || deployment.id, + }]; + })); +} diff --git a/src/agents/plugins/opencode/opencode-model-configs.ts b/src/agents/plugins/opencode/opencode-model-configs.ts index 81ff5e0d8..7fd5282fa 100644 --- a/src/agents/plugins/opencode/opencode-model-configs.ts +++ b/src/agents/plugins/opencode/opencode-model-configs.ts @@ -627,6 +627,9 @@ export function getResponsesApiModelConfigs( * Family-specific defaults for unknown model variants. * Used by getModelConfig() when an exact match isn't found but * the model ID prefix matches a known family. + * + * Keys are matched against both the full model ID and the part after the + * first dot (to handle vendor-prefixed names like "anthropic.claude-…"). */ const MODEL_FAMILY_DEFAULTS: Record> = { 'claude': { @@ -662,6 +665,22 @@ const MODEL_FAMILY_DEFAULTS: Record> = { temperature: true, modalities: { input: ['text'], output: ['text'] }, limit: { context: 262000, output: 65536 } + }, + 'llama': { + family: 'llama', + reasoning: true, + attachment: false, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + limit: { context: 128000, output: 8192 } + }, + 'mistral': { + family: 'mistral', + reasoning: true, + attachment: false, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + limit: { context: 32000, output: 8192 } } }; @@ -685,14 +704,27 @@ export function getModelConfig(modelId: string): OpenCodeModelConfig { return config; } - // Detect model family from prefix for smarter defaults + // Strip vendor prefix (e.g. "anthropic.claude-..." → "claude-...") and + // try the catalogue again so that Azure-style deployment names resolve to + // the correct static config when one exists. + const bareName = modelId.includes('.') ? modelId.split('.').slice(1).join('.') : modelId; + const bareConfig = bareName !== modelId ? OPENCODE_MODEL_CONFIGS[bareName] : undefined; + if (bareConfig) { + // Return the static config but with the original (Azure) deployment id + // so OpenCode routes it to the right provider entry. + return { ...bareConfig, id: modelId, name: bareConfig.name, displayName: bareConfig.displayName ?? modelId }; + } + + // Detect model family from prefix for smarter defaults. + // Check both the full id and the bare name (after stripping vendor prefix). const familyPrefix = Object.keys(MODEL_FAMILY_DEFAULTS).find( - prefix => modelId.startsWith(prefix) + prefix => modelId.startsWith(prefix) || bareName.startsWith(prefix) ); const familyDefaults = familyPrefix ? MODEL_FAMILY_DEFAULTS[familyPrefix] : {}; // Extract family from model ID (e.g., "gpt-4o" -> "gpt-4", "claude-4-5-sonnet" -> "claude-4") const family = familyDefaults.family + || bareName.split('-').slice(0, 2).join('-') || modelId.split('-').slice(0, 2).join('-') || modelId; diff --git a/src/agents/plugins/opencode/opencode.plugin.ts b/src/agents/plugins/opencode/opencode.plugin.ts index a90927c4f..b68887c20 100644 --- a/src/agents/plugins/opencode/opencode.plugin.ts +++ b/src/agents/plugins/opencode/opencode.plugin.ts @@ -2,7 +2,11 @@ import { join } from 'path'; import type { AgentMetadata, AgentConfig } from '../../core/types.js'; import { logger } from '../../../utils/logger.js'; import { getModelConfig, getChatCompletionsModelConfigs, getResponsesApiModelConfigs } from './opencode-model-configs.js'; -import { fetchDynamicModelConfigs } from './opencode-dynamic-models.js'; +import { + fetchAzureOpenCodeModelConfigs, + fetchDynamicModelConfigs, +} from './opencode-dynamic-models.js'; +import { getAzureConnectionConfig } from '../../../providers/core/azure-deployment-catalog.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; @@ -110,7 +114,7 @@ export const OpenCodePluginMetadata: AgentMetadata = { apiKey: [], model: [] }, - supportedProviders: ['litellm', 'ai-run-sso', 'ollama', 'bedrock', 'bearer-auth'], + supportedProviders: ['litellm', 'ai-run-sso', 'ollama', 'bedrock', 'bearer-auth', 'azure-openai'], ssoConfig: { enabled: true, clientType: OPENCODE_CLIENT_TYPE }, // Tool names are lower-cased before they reach the aggregator, and @@ -273,7 +277,13 @@ export const OpenCodePluginMetadata: AgentMetadata = { // boundaries at all, so active_duration_ms stayed 0. env.OPENCODE_HOOKS = JSON.stringify({ hooks: buildMergedHooks(env) }); - const provider = env.CODEMIE_PROVIDER; + function normalizeProvider(provider: string | undefined, baseUrl: string | undefined): string | undefined { + if (provider === 'azure-openai') return 'azure-openai'; + if (provider === 'bedrock' && baseUrl && /openai\.azure\.com/i.test(baseUrl)) return 'azure-openai'; + return provider; + } + + const provider = normalizeProvider(env.CODEMIE_PROVIDER, env.CODEMIE_BASE_URL); const baseUrl = env.CODEMIE_BASE_URL; if (!baseUrl) { @@ -285,17 +295,16 @@ export const OpenCodePluginMetadata: AgentMetadata = { return env; } - // Fetch live model catalogue from the CodeMie API. - // Falls back to the static OPENCODE_MODEL_CONFIGS on any error. - const allModels = await fetchDynamicModelConfigs( - baseUrl, - env.CODEMIE_URL, - env.CODEMIE_JWT_TOKEN, - ); - // Model selection priority: env var > config > default // Use dynamic catalogue first, then fall back to static getModelConfig for unknown IDs. const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; + const allModels = provider === 'azure-openai' + ? await fetchAzureOpenCodeModelConfigs(getAzureConnectionConfig(env), selectedModel) + : await fetchDynamicModelConfigs( + baseUrl, + env.CODEMIE_URL, + env.CODEMIE_JWT_TOKEN, + ); const modelConfig = allModels[selectedModel] ?? getModelConfig(selectedModel); const { providerOptions } = modelConfig; @@ -306,7 +315,8 @@ export const OpenCodePluginMetadata: AgentMetadata = { // Determine URLs based on provider type const isBedrock = provider === 'bedrock'; - const proxyBaseUrl = provider !== 'ollama' && !isBedrock ? baseUrl : undefined; + const isProxy = provider !== 'ollama' && !isBedrock; + const proxyBaseUrl = isProxy ? baseUrl : undefined; const ollamaBaseUrl = provider === 'ollama' ? (baseUrl.endsWith('/v1') || baseUrl.includes('/v1/') ? baseUrl : `${baseUrl.replace(/\/$/, '')}/v1`) : 'http://localhost:11434/v1'; @@ -315,8 +325,17 @@ export const OpenCodePluginMetadata: AgentMetadata = { // - ollama: uses ollama provider directly // - bedrock: uses OpenCode's built-in amazon-bedrock provider (AWS env vars set by provider hook) // - all others: route through codemie-proxy (SSO/proxy) - const activeProvider = provider === 'ollama' ? 'ollama' : (isBedrock ? 'amazon-bedrock' : 'codemie-proxy'); const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; + const modelId = isBedrock + ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) + : modelConfig.id; + const activeProvider = provider === 'ollama' + ? 'ollama' + : (isBedrock + ? 'amazon-bedrock' + : (provider === 'azure-openai' + ? (Object.prototype.hasOwnProperty.call(responsesApiModels, modelId) ? 'openai' : 'azure-openai') + : 'codemie-proxy')); // Always enable openai CUSTOM_LOADER when Responses API models exist. // This fixes model-switching: if user starts with Claude and switches to GPT, @@ -328,10 +347,16 @@ export const OpenCodePluginMetadata: AgentMetadata = { const hasResponsesApiModels = Object.keys(responsesApiModels).length > 0; const openCodeConfig: Record = { - enabled_providers: ['codemie-proxy', 'openai', 'ollama', 'amazon-bedrock'], + enabled_providers: [ + 'codemie-proxy', + 'openai', + 'ollama', + 'amazon-bedrock', + ...(provider === 'azure-openai' ? ['azure-openai'] : []), + ], share: 'disabled', provider: { - ...(proxyBaseUrl && { + ...(proxyBaseUrl && provider !== 'azure-openai' && { 'codemie-proxy': { npm: '@ai-sdk/openai-compatible', name: 'CodeMie SSO', @@ -344,6 +369,19 @@ export const OpenCodePluginMetadata: AgentMetadata = { models: chatModels } }), + ...(proxyBaseUrl && provider === 'azure-openai' && { + 'azure-openai': { + npm: '@ai-sdk/openai-compatible', + name: 'Azure OpenAI', + options: { + baseURL: `${proxyBaseUrl}/`, + apiKey: 'proxy-handled', + timeout, + ...(providerOptions?.headers && { headers: providerOptions.headers }) + }, + models: chatModels + } + }), // Built-in openai CUSTOM_LOADER: routes Responses API models via sdk.responses() ...(proxyBaseUrl && hasResponsesApiModels && { openai: { @@ -370,7 +408,7 @@ export const OpenCodePluginMetadata: AgentMetadata = { } } }, - model: `${activeProvider}/${isBedrock ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : modelConfig.id}` + model: `${activeProvider}/${modelId}` }; // Inject the shell-hooks plugin — it is what delivers OPENCODE_HOOKS diff --git a/src/agents/plugins/openwiki/openwiki.plugin.ts b/src/agents/plugins/openwiki/openwiki.plugin.ts index ce6dbad8e..41db87a4f 100644 --- a/src/agents/plugins/openwiki/openwiki.plugin.ts +++ b/src/agents/plugins/openwiki/openwiki.plugin.ts @@ -35,7 +35,7 @@ export const OpenWikiPluginMetadata: AgentMetadata = { apiKey: ['OPENAI_COMPATIBLE_API_KEY'], model: ['OPENWIKI_MODEL_ID'], }, - supportedProviders: ['ai-run-sso', 'bearer-auth', 'litellm', 'ollama', 'moonshot-subscription'], + supportedProviders: ['ai-run-sso', 'bearer-auth', 'litellm', 'ollama', 'moonshot-subscription', 'azure-openai'], blockedModelPatterns: [], ssoConfig: { enabled: true, clientType: 'codemie-openwiki' }, ownedSubcommands: ['init'], diff --git a/src/agents/plugins/pi/pi.models.ts b/src/agents/plugins/pi/pi.models.ts index 6aefac7b0..730ccf678 100644 --- a/src/agents/plugins/pi/pi.models.ts +++ b/src/agents/plugins/pi/pi.models.ts @@ -2,10 +2,15 @@ import { mkdir, writeFile } from 'fs/promises'; import type { LlmModel } from '../../../providers/plugins/sso/sso.http-client.js'; import { fetchCodeMieLlmModels } from '../../../providers/plugins/sso/sso.http-client.js'; import { CodeMieSSO } from '../../../providers/plugins/sso/sso.auth.js'; +import { ConfigurationError } from '../../../utils/errors.js'; import { logger } from '../../../utils/logger.js'; import type { ModelPrice } from '../../../utils/pricing.js'; import { lookupPrice } from '../../../utils/pricing.js'; import { getPiAgentDir, getPiModelsPath } from './pi.paths.js'; +import { + fetchAzureDeploymentModels, + getAzureConnectionConfig, +} from '../../../providers/core/azure-deployment-catalog.js'; export interface PiModelClassification { provider: 'codemie-proxy' | 'codemie-anthropic'; @@ -196,9 +201,14 @@ function resolveModelCost(model: LlmModel, id: string): PiModelCost | undefined return unpriced ? undefined : cost; } -export function convertLlmModelToPiEntry(model: LlmModel): PiModelEntry { +export function convertLlmModelToPiEntry( + model: LlmModel, + modelHint?: string, + forceChat = false, +): PiModelEntry { const id = model.deployment_name || model.base_name || model.label; - const limits = detectLimits(id); + const capabilityId = modelHint?.trim() || id; + const limits = detectLimits(capabilityId); const entry: PiModelEntry = { id, @@ -208,21 +218,23 @@ export function convertLlmModelToPiEntry(model: LlmModel): PiModelEntry { maxTokens: limits.maxTokens, }; - const classification = classifyPiModel(id); + const classification = forceChat + ? { provider: 'codemie-proxy' as const } + : classifyPiModel(capabilityId); if (classification.api) { entry.api = classification.api; } - if (isReasoningModel(id)) { + if (isReasoningModel(capabilityId)) { entry.reasoning = true; entry.thinkingLevelMap = defaultThinkingLevelMap(); } if ( - id.startsWith('claude-sonnet-4-6') || - id.startsWith('claude-sonnet-5') || - /^claude-opus-4-[6-8]/.test(id) || - id.startsWith('claude-opus-5') + capabilityId.startsWith('claude-sonnet-4-6') || + capabilityId.startsWith('claude-sonnet-5') || + /^claude-opus-4-[6-8]/.test(capabilityId) || + capabilityId.startsWith('claude-opus-5') ) { entry.compat = { forceAdaptiveThinking: true }; } @@ -273,12 +285,15 @@ function buildModelsConfig( entries: PiModelEntry[], baseUrl: string, apiKey: string, + forceProxy = false, ): PiModelsConfig { const proxyModels: PiModelEntry[] = []; const anthropicModels: PiModelEntry[] = []; for (const entry of entries) { - const classification = classifyPiModel(entry.id); + const classification = forceProxy + ? { provider: 'codemie-proxy' as const } + : classifyPiModel(entry.id); if (classification.provider === 'codemie-anthropic') { anthropicModels.push(entry); } else { @@ -411,6 +426,31 @@ export async function fetchAndBuildPiModels( return; } + if (env.CODEMIE_PROVIDER === 'azure-openai') { + const selectedModel = env.CODEMIE_MODEL; + if (!selectedModel) { + throw new ConfigurationError('No Azure OpenAI deployment configured for codemie-pi. Run codemie setup to select a deployment.'); + } + + const deployments = await fetchAzureDeploymentModels( + getAzureConnectionConfig(env), + selectedModel, + ); + const entries = deployments.map(deployment => { + const synthetic = createSyntheticLlmModel(deployment.id); + synthetic.label = deployment.name || deployment.id; + const modelHint = typeof deployment.metadata?.model === 'string' + ? deployment.metadata.model + : undefined; + return convertLlmModelToPiEntry(synthetic, modelHint, true); + }); + const baseUrl = env.CODEMIE_BASE_URL || 'http://127.0.0.1'; + const apiKey = 'proxy-handled'; + const config = buildModelsConfig(entries, baseUrl, apiKey, true); + await writeFile(getPiModelsPath(cwd), JSON.stringify(config, null, 2), 'utf-8'); + return; + } + const baseUrl = env.CODEMIE_BASE_URL || ''; const apiKey = env.CODEMIE_API_KEY || 'proxy-handled'; @@ -419,7 +459,7 @@ export async function fetchAndBuildPiModels( const rawModels = await fetchCodeMieModels(env); entries = rawModels .filter(model => model.enabled) - .map(convertLlmModelToPiEntry); + .map(model => convertLlmModelToPiEntry(model)); logger.debug(`[pi-models] Loaded ${entries.length} models from CodeMie API`); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/agents/plugins/pi/pi.packages.ts b/src/agents/plugins/pi/pi.packages.ts index 43cb7b793..dbb9b644a 100644 --- a/src/agents/plugins/pi/pi.packages.ts +++ b/src/agents/plugins/pi/pi.packages.ts @@ -1,6 +1,7 @@ import { exec, type ExecResult } from '@/utils/exec.js'; import { logger } from '@/utils/logger.js'; import { AgentInstallationError } from '@/utils/errors.js'; +import { getCommandPath } from '@/utils/processes.js'; export const REQUIRED_PI_PACKAGES: readonly string[] = [ 'git:github.com/obra/superpowers', @@ -22,7 +23,13 @@ const DEFAULT_TIMEOUT_MS = 300_000; export async function installRequiredPiPackages( options: InstallPiPackagesOptions = {}, ): Promise { - const cliCommand = options.cliCommand || 'pi'; + const requestedCommand = options.cliCommand || 'pi'; + const windowsCommand = requestedCommand.toLowerCase().endsWith('.cmd') + ? requestedCommand + : `${requestedCommand}.cmd`; + const cliCommand = process.platform === 'win32' + ? await getCommandPath(windowsCommand) || requestedCommand + : requestedCommand; const cwd = options.cwd ?? process.cwd(); const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS; @@ -36,6 +43,7 @@ export async function installRequiredPiPackages( result = await exec(cliCommand, ['install', pkg], { cwd, timeout, + shell: process.platform === 'win32', }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/agents/plugins/pi/pi.plugin.ts b/src/agents/plugins/pi/pi.plugin.ts index 1cad252d2..8a23c1a29 100644 --- a/src/agents/plugins/pi/pi.plugin.ts +++ b/src/agents/plugins/pi/pi.plugin.ts @@ -279,7 +279,7 @@ export const PiPluginMetadata: AgentMetadata = { model: [], }, - supportedProviders: ['ai-run-sso', 'bearer-auth', 'litellm', 'ollama'], + supportedProviders: ['ai-run-sso', 'bearer-auth', 'litellm', 'ollama', 'azure-openai'], ssoConfig: { enabled: true, @@ -339,16 +339,15 @@ export const PiPluginMetadata: AgentMetadata = { // CodeMie backends and would misroute them to `codemie-proxy`. const providerId = process.env.CODEMIE_PROVIDER === 'ollama' ? 'ollama' - : classifyPiModel(model).provider; + : process.env.CODEMIE_PROVIDER === 'azure-openai' + ? 'codemie-proxy' + : classifyPiModel(model).provider; // `--task` is not handled here: `flagMappings` rewrites it to Pi's `-p`, which // BaseAgentAdapter applies after this hook. Consuming it here would leave a bare // positional, and Pi treats a bare positional as an interactive opening prompt. const result = ['--provider', providerId, '--model', model, ...args]; - // Share CodeMie's session id with Pi so discovery can exact-match the - // transcript. Pi exits with code 1 if this is combined with a flag that - // already selects a session, so those runs correlate by run window instead. const sessionId = process.env.CODEMIE_SESSION_ID; if (sessionId && !result.includes('--session-id') && !hasSessionSelectionFlag(result)) { result.push('--session-id', sessionId); diff --git a/src/cli/commands/doctor/README.md b/src/cli/commands/doctor/README.md index 6e8060290..b65790170 100644 --- a/src/cli/commands/doctor/README.md +++ b/src/cli/commands/doctor/README.md @@ -193,7 +193,7 @@ The system currently supports these providers: - **ai-run-sso**: AI-Run SSO authentication with integration validation - **openai**: OpenAI API with model verification -- **azure**: Azure OpenAI with endpoint validation +- **azure-openai**: Azure OpenAI with endpoint validation - **litellm**: LiteLLM proxy gateway - **gemini**: Google Gemini API diff --git a/src/cli/commands/doctor/checks/AIConfigCheck.ts b/src/cli/commands/doctor/checks/AIConfigCheck.ts index c622b86f4..da7215749 100644 --- a/src/cli/commands/doctor/checks/AIConfigCheck.ts +++ b/src/cli/commands/doctor/checks/AIConfigCheck.ts @@ -84,6 +84,7 @@ export class AIConfigCheck implements HealthCheck { } else { onProgress?.('Checking base URL'); // For other providers, show Base URL + // Azure OpenAI stores its endpoint in baseUrl (set by buildConfig) if (hasBaseUrl) { details.push({ status: 'ok', diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index aab79357b..734dc499e 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -155,6 +155,7 @@ export function createSetupCommand(): Command { async function runSetupWizard(force?: boolean): Promise { // Show ecosystem introduction + logger.debug('[setup] starting setup wizard'); FirstTimeExperience.showEcosystemIntro(); // Check if config already exists (both global and local) @@ -371,6 +372,7 @@ async function runSetupWizard(force?: boolean): Promise { enforcementContext, codeMieSession ); + logger.debug(`[setup] handlePluginSetup completed for ${provider}`); } /** @@ -416,8 +418,9 @@ async function handlePluginSetup( try { models = await setupSteps.fetchModels(credentials); modelsSpinner.succeed(chalk.green(`Found ${models.length} available models`)); - } catch { + } catch (error) { modelsSpinner.warn(chalk.yellow('Could not fetch models - will use manual entry')); + logger.warn('[setup] Could not fetch models', { error: error instanceof Error ? error.message : String(error) }); models = []; } @@ -427,11 +430,15 @@ async function handlePluginSetup( ? await setupSteps.selectModel(credentials, models, providerTemplate) : undefined; + logger.debug(`[setup] selectModel result: ${preselectedModel ?? 'none'}`); + if (preselectedModel) { selectedModel = preselectedModel; logger.success(`Model selected automatically: ${selectedModel}`); } else { + logger.debug('[setup] falling back to manual model selection'); selectedModel = await promptForModelSelection(models, providerTemplate); + logger.debug(`[setup] manual model selected: ${selectedModel}`); } // Step 3.5: Install model if provider supports it (e.g., Ollama) @@ -451,6 +458,7 @@ async function handlePluginSetup( } // Step 4: Build configuration + logger.debug('[setup] building final configuration'); const config = setupSteps.buildConfig(credentials, selectedModel); const userEmail = credentials.additionalConfig?.userEmail as string | undefined; @@ -463,13 +471,29 @@ async function handlePluginSetup( config.sonnetModel = modelTiers.sonnetModel; config.opusModel = modelTiers.opusModel; + // --- FIX: Handle Profile Updates --- + if (isUpdate && profileName) { + const workingDir = process.cwd(); + const currentProfile = await ConfigLoader.getProfile(profileName, workingDir); + if (currentProfile) { + // Merge new setup config into the existing profile + Object.assign(currentProfile, config); + // Update the config object to be the merged result for the save step + Object.assign(config, currentProfile); + config.name = profileName; + } + } + // --------------------------------- + // Step 5: Ask for profile name (if creating new) let finalProfileName = profileName; if (!isUpdate && profileName === null) { finalProfileName = await promptForProfileName(providerName); } + // Step 6: Save profile + logger.debug('[setup] saving profile'); const saveSpinner = ora('Saving profile...').start(); try { @@ -536,6 +560,7 @@ async function handlePluginSetup( } // Display success + logger.debug('[setup] setup completed successfully'); displaySetupSuccess(finalProfileName!, providerName, selectedModel); // Show next steps based on storage location @@ -560,6 +585,7 @@ async function handlePluginSetup( } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); const providerTemplate = ProviderRegistry.getProvider(providerName); + logger.error(`[setup] plugin setup failed for provider ${providerName}: ${errorMessage}`); displaySetupError(new Error(errorMessage), providerTemplate?.setupInstructions); throw error; } @@ -618,6 +644,7 @@ async function promptForModelSelection( models: string[], providerTemplate?: any ): Promise { + logger.debug(`[setup] promptForModelSelection: models=${models.length}`); if (models.length === 0) { const { manualModel } = await inquirer.prompt([ { @@ -628,6 +655,7 @@ async function promptForModelSelection( validate: (input: string) => input.trim() !== '' || 'Model name is required' } ]); + logger.debug(`[setup] manual model input accepted: ${manualModel}`); return manualModel ? manualModel.trim() : manualModel; } @@ -647,6 +675,8 @@ async function promptForModelSelection( } ]); + logger.debug(`[setup] model list selection accepted: ${selectedModel}`); + if (selectedModel === 'custom') { const { customModel } = await inquirer.prompt([ { @@ -656,6 +686,7 @@ async function promptForModelSelection( validate: (input: string) => input.trim() !== '' || 'Model is required' } ]); + logger.debug(`[setup] custom model input accepted: ${customModel}`); return customModel ? customModel.trim() : customModel; } diff --git a/src/env/types.ts b/src/env/types.ts index e37ead7ac..243a751ba 100644 --- a/src/env/types.ts +++ b/src/env/types.ts @@ -52,6 +52,7 @@ export interface ProviderProfile { name?: string; // Optional - set during save provider?: string; baseUrl?: string; + azureOpenAIBaseUrl?: string; apiKey?: string; model?: string; /** Reasoning/thinking effort level. Persisted profile default; CLI flag overrides. */ @@ -93,6 +94,10 @@ export interface ProviderProfile { maxOutputTokens?: number; maxThinkingTokens?: number; + // Azure OpenAI-specific fields + azureApiVersion?: string; + azureDeployment?: string; + // In-memory assistants/skills state (not persisted here; stored at MultiProviderConfig level) codemieAssistants?: CodemieAssistant[]; } diff --git a/src/providers/core/azure-deployment-catalog.ts b/src/providers/core/azure-deployment-catalog.ts new file mode 100644 index 000000000..0cb9a57d0 --- /dev/null +++ b/src/providers/core/azure-deployment-catalog.ts @@ -0,0 +1,65 @@ +import type { CodeMieConfigOptions } from '../../env/types.js'; +import type { ModelInfo } from './types.js'; +import { ProviderRegistry } from './registry.js'; +import { logger } from '../../utils/logger.js'; +import { sanitizeLogArgs } from '../../utils/security.js'; + +export interface AzureConnectionConfig { + endpoint?: string; + apiKey?: string; + apiVersion?: string; +} + +export function getAzureConnectionConfig(env: NodeJS.ProcessEnv): AzureConnectionConfig { + let profile: Partial = {}; + if (env.CODEMIE_PROFILE_CONFIG) { + try { + profile = JSON.parse(env.CODEMIE_PROFILE_CONFIG) as Partial; + } catch { + profile = {}; + } + } + + return { + endpoint: env.CODEMIE_AZURE_OPENAI_BASE_URL || profile.baseUrl || env.CODEMIE_BASE_URL, + apiKey: profile.apiKey || env.CODEMIE_API_KEY || undefined, + apiVersion: profile.azureApiVersion || env.CODEMIE_AZURE_OPENAI_API_VERSION || env.AZURE_OPENAI_API_VERSION, + }; +} + +export async function fetchAzureDeploymentModels( + connection: AzureConnectionConfig, + selectedModel: string, +): Promise { + const selected: ModelInfo = { id: selectedModel, name: selectedModel }; + if (!connection.endpoint) return [selected]; + + try { + const fetcher = ProviderRegistry.getModelProxy('azure-openai'); + if (!fetcher) return [selected]; + + const models = await fetcher.fetchModels({ + provider: 'azure-openai', + baseUrl: connection.endpoint, + apiKey: connection.apiKey, + model: selectedModel, + timeout: 300, + azureApiVersion: connection.apiVersion, + } as CodeMieConfigOptions); + + if (!models.some(model => model.id === selectedModel)) { + models.unshift(selected); + } + return models; + } catch (error) { + return logAzureDeploymentFallback(selected, error); + } +} + +function logAzureDeploymentFallback(selected: ModelInfo, error: unknown): ModelInfo[] { + logger.debug( + '[azure-models] Failed to fetch deployments, using selected deployment only', + ...sanitizeLogArgs({ error: error instanceof Error ? error.message : String(error) }), + ); + return [selected]; +} \ No newline at end of file diff --git a/src/providers/core/registry.ts b/src/providers/core/registry.ts index 33f4f3b51..ce59e33d0 100644 --- a/src/providers/core/registry.ts +++ b/src/providers/core/registry.ts @@ -51,6 +51,15 @@ export class ProviderRegistry { this.setupSteps.set(name, steps); } + /** + * Register provider template together with its setup steps + */ + static registerProviderSetup(template: T, steps: ProviderSetupSteps): T { + this.registerProvider(template); + this.registerSetupSteps(template.name, steps); + return template; + } + /** * Get provider by name */ diff --git a/src/providers/index.ts b/src/providers/index.ts index 9f0b6b92e..4768ad00c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -27,6 +27,11 @@ export { registerProvider } from './core/decorators.js'; export { BaseHealthCheck } from './core/base/BaseHealthCheck.js'; export { BaseModelProxy } from './core/base/BaseModelProxy.js'; export { HTTPClient } from './core/base/http-client.js'; +export { + fetchAzureDeploymentModels, + getAzureConnectionConfig, +} from './core/azure-deployment-catalog.js'; +export type { AzureConnectionConfig } from './core/azure-deployment-catalog.js'; export type { HTTPClientConfig, HTTPResponse } from './core/base/http-client.js'; // Import plugins to trigger auto-registration @@ -38,6 +43,7 @@ import './plugins/litellm/index.js'; import './plugins/bedrock/index.js'; import './plugins/anthropic-subscription/index.js'; import './plugins/moonshot-subscription/index.js'; +import './plugins/azure-openai/index.js'; // Re-export plugin modules for direct access if needed export * as Ollama from './plugins/ollama/index.js'; @@ -47,3 +53,4 @@ export * as LiteLLM from './plugins/litellm/index.js'; export * as Bedrock from './plugins/bedrock/index.js'; export * as AnthropicSubscription from './plugins/anthropic-subscription/index.js'; export * as MoonshotSubscription from './plugins/moonshot-subscription/index.js'; +export * as AzureOpenAI from './plugins/azure-openai/index.js'; diff --git a/src/providers/integration/setup-ui.ts b/src/providers/integration/setup-ui.ts index 2e2ffd229..dae6f566e 100644 --- a/src/providers/integration/setup-ui.ts +++ b/src/providers/integration/setup-ui.ts @@ -6,7 +6,7 @@ */ import chalk from 'chalk'; -import type { ProviderTemplate } from '../core/types.js'; +import type { ProviderTemplate, ModelInfo } from '../core/types.js'; import { getSystemCapabilities, modelFitsSystem } from '../../utils/hardware.js'; /** @@ -179,6 +179,15 @@ export function formatModelChoice( ): { name: string; value: string; disabled?: boolean | string } { const metadata = template?.modelMetadata?.[modelId]; + // Note for Azure OpenAI models — reasoning/thinking disabled + let featureNote = ''; + if ( + template?.name === 'azure-openai' && + (!modelId.toLowerCase().includes('gpt') && !modelId.toLowerCase().includes('openai')) + ) { + featureNote = ' [no reasoning]'; + } + // Check if model is recommended. `isRecommendedOverride` — precomputed by // getAllModelChoices via computeRecommendedModelIds so only the latest // version per family is starred — wins when provided; otherwise fall back @@ -199,11 +208,11 @@ export function formatModelChoice( // If no metadata and not recommended, return plain format if (!metadata && !isRecommended) { - return { name: modelId, value: modelId, disabled }; + return { name: modelId + featureNote, value: modelId, disabled }; } const popularBadge = isRecommended ? chalk.yellow('⭐ ') : ''; - const mainLine = `${popularBadge}${chalk.white.bold(metadata?.name || modelId)}`; + const mainLine = `${popularBadge}${chalk.white.bold(metadata?.name || modelId)}${featureNote}`; const details: string[] = []; if (metadata?.description) { @@ -237,25 +246,41 @@ export function formatModelChoice( * exceeds the current system are included but disabled with an explanation. */ export function getAllModelChoices( - models: string[], + models: string[] | ModelInfo[], template?: ProviderTemplate ): Array<{ name: string; value: string; disabled?: boolean | string }> { - const recommendedIds = computeRecommendedModelIds(models, template?.recommendedModels); + const normalizedModels = models.map(model => typeof model === 'string' ? model : model.id); + const infoMap = new Map(); + for (const model of models) { + if (typeof model !== 'string') { + infoMap.set(model.id, model); + } + } + + const recommendedIds = computeRecommendedModelIds(normalizedModels, template?.recommendedModels); // Sort models using common rules - const sortedModels = [...models].sort((a, b) => { + const sortedModels = [...normalizedModels].sort((a, b) => { const aRecommended = recommendedIds.has(a); const bRecommended = recommendedIds.has(b); - // Recommended models first if (aRecommended && !bRecommended) return -1; if (!aRecommended && bRecommended) return 1; - // Then sort alphabetically return a.localeCompare(b); }); - return sortedModels.map(model => formatModelChoice(model, template, recommendedIds.has(model))); + return sortedModels.map(model => { + const choice = formatModelChoice(model, template, recommendedIds.has(model)); + const modelInfo = infoMap.get(model); + if (modelInfo?.description && !template?.modelMetadata?.[model]?.description) { + return { + ...choice, + name: `${choice.name}\n ${chalk.dim(modelInfo.description)}` + }; + } + return choice; + }); } /** diff --git a/src/providers/plugins/azure-openai/__tests__/azure-openai.template.test.ts b/src/providers/plugins/azure-openai/__tests__/azure-openai.template.test.ts new file mode 100644 index 000000000..388640868 --- /dev/null +++ b/src/providers/plugins/azure-openai/__tests__/azure-openai.template.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { AzureOpenAITemplate } from '../azure-openai.template.js'; + +function makeEnv(overrides: Record = {}): NodeJS.ProcessEnv { + return { + CODEMIE_PROVIDER: 'azure-openai', + CODEMIE_BASE_URL: 'https://my-azure-openai.example.com', + CODEMIE_AZURE_OPENAI_BASE_URL: 'https://my-azure-openai.example.com', + CODEMIE_API_KEY: 'PLACEHOLDER-KEY-FOR-TESTING-ONLY', + CODEMIE_MODEL: 'anthropic.claude-sonnet-4-6', + AZURE_OPENAI_API_KEY: 'PLACEHOLDER-KEY-FOR-TESTING-ONLY', // set by wildcard hook first + ANTHROPIC_BASE_URL: 'https://my-azure-openai.example.com', + ANTHROPIC_AUTH_TOKEN: 'PLACEHOLDER-KEY-FOR-TESTING-ONLY', + ...overrides, + }; +} + +async function runWildcardBeforeRunHook(env: NodeJS.ProcessEnv): Promise { + const wildcardHook = AzureOpenAITemplate.agentHooks?.['*']?.beforeRun; + if (!wildcardHook) { + throw new Error('wildcard beforeRun hook not found in AzureOpenAITemplate'); + } + return wildcardHook(env, { agent: 'codemie-code', agentDisplayName: 'CodeMie Code' } as any); +} + +describe('AzureOpenAITemplate — generic provider hook', () => { + it('exports Azure endpoint, API version, deployment, and key', async () => { + const result = await runWildcardBeforeRunHook(makeEnv({ + CODEMIE_MODEL: 'deployment-a', + })); + + expect(result.AZURE_OPENAI_ENDPOINT).toBe('https://my-azure-openai.example.com'); + expect(result.AZURE_OPENAI_API_VERSION).toBe('2025-04-01-preview'); + expect(result.AZURE_OPENAI_DEPLOYMENT).toBe('deployment-a'); + expect(result.AZURE_OPENAI_API_KEY).toBe('PLACEHOLDER-KEY-FOR-TESTING-ONLY'); + }); + + it('preserves the Azure endpoint when CODEMIE_BASE_URL is the local proxy', async () => { + const result = await runWildcardBeforeRunHook(makeEnv({ + CODEMIE_BASE_URL: 'http://localhost:3001', + CODEMIE_AZURE_OPENAI_BASE_URL: 'https://real-azure-openai.example.com', + })); + + expect(result.AZURE_OPENAI_ENDPOINT).toBe('https://real-azure-openai.example.com'); + }); + + it('does not expose a direct Claude-specific Azure hook', () => { + expect(AzureOpenAITemplate.agentHooks?.['claude']).toBeUndefined(); + }); +}); diff --git a/src/providers/plugins/azure-openai/azure-openai.health.ts b/src/providers/plugins/azure-openai/azure-openai.health.ts new file mode 100644 index 000000000..731566971 --- /dev/null +++ b/src/providers/plugins/azure-openai/azure-openai.health.ts @@ -0,0 +1,99 @@ +/** + * Azure OpenAI Health Check Implementation + * + * Validates Azure OpenAI endpoint availability and deployment discovery. + */ + +import type { CodeMieConfigOptions } from '../../../env/types.js'; +import type { HealthCheckResult, ModelInfo } from '../../core/types.js'; +import { BaseHealthCheck } from '../../core/base/BaseHealthCheck.js'; +import { ProviderRegistry } from '../../core/registry.js'; +import { AzureOpenAITemplate } from './azure-openai.template.js'; +import { AzureOpenAIModelProxy } from './azure-openai.models.js'; +import { ConfigurationError } from '../../../utils/errors.js'; + +export class AzureOpenAIHealthCheck extends BaseHealthCheck { + private modelProxy: AzureOpenAIModelProxy; + private azureApiVersion = '2025-04-01-preview'; + private azureDeployment?: string; + private azureApiKey?: string; + // Tracks the effective endpoint after check() is called (not the constructor default). + private activeBaseUrl: string; + + constructor(baseUrl: string = AzureOpenAITemplate.defaultBaseUrl) { + super({ + provider: 'azure-openai', + baseUrl, + timeout: 10000 + }); + this.activeBaseUrl = baseUrl; + this.modelProxy = new AzureOpenAIModelProxy(baseUrl); + } + + supports(provider: string): boolean { + return provider === 'azure-openai'; + } + + async check(config: CodeMieConfigOptions): Promise { + this.azureApiVersion = config.azureApiVersion || '2025-04-01-preview'; + this.azureDeployment = config.azureDeployment || config.model; + this.azureApiKey = config.apiKey; + // Always use the runtime endpoint from config, NOT this.config.baseUrl which + // is frozen to the constructor default (the auto-registered singleton is created + // without an endpoint, so this.config.baseUrl would be the placeholder URL). + this.activeBaseUrl = config.baseUrl || AzureOpenAITemplate.defaultBaseUrl; + this.modelProxy = new AzureOpenAIModelProxy( + this.activeBaseUrl, + config.apiKey, + this.azureApiVersion + ); + return super.check(config); + } + + protected async ping(): Promise { + const models = await this.listModels(); + if (models.length === 0) { + throw new ConfigurationError('No Azure OpenAI deployments found. Verify that at least one deployment exists and that the API version matches the resource.'); + } + } + + protected async getVersion(): Promise { + return `api-version: ${this.azureApiVersion}`; + } + + async listModels(): Promise { + // Use this.activeBaseUrl (set in check()) rather than this.config.baseUrl + // which is frozen to the placeholder URL from the auto-registered singleton. + return this.modelProxy.fetchModels({ + provider: 'azure-openai', + baseUrl: this.activeBaseUrl, + apiKey: this.azureApiKey, + model: this.azureDeployment || 'temp', + timeout: 300, + azureApiVersion: this.azureApiVersion + } as CodeMieConfigOptions); + } + + protected getUnreachableResult(): HealthCheckResult { + const endpoint = this.activeBaseUrl || AzureOpenAITemplate.defaultBaseUrl; + const apiVersion = this.azureApiVersion; + return { + provider: 'azure-openai', + status: 'unreachable', + message: 'Cannot connect to Azure OpenAI', + remediation: `Check Azure OpenAI configuration:\n 1. Verify the resource endpoint is correct: ${endpoint}\n 2. Verify the API key is valid\n 3. Verify the deployment exists and is accessible\n 4. Ensure the API version is supported: ${apiVersion}\n 5. Ensure the deployment name matches the Azure OpenAI Studio deployment\n\nSetup Azure OpenAI:\n - Create a resource in Azure Portal\n - Deploy a model in Azure OpenAI Studio\n - Configure endpoint, key, API version, and deployment name in CodeMie` + }; + } + + protected getHealthyMessage(models: ModelInfo[]): string { + return models.length > 0 + ? `Azure OpenAI is accessible with ${models.length} deployment(s) available${this.azureDeployment ? ` (active: ${this.azureDeployment})` : ''}` + : 'Azure OpenAI is accessible'; + } + + protected getNoModelsRemediation(): string { + return 'Create a deployment in Azure OpenAI Studio and try again.'; + } +} + +ProviderRegistry.registerHealthCheck('azure-openai', new AzureOpenAIHealthCheck()); diff --git a/src/providers/plugins/azure-openai/azure-openai.models.ts b/src/providers/plugins/azure-openai/azure-openai.models.ts new file mode 100644 index 000000000..bb7cfcf11 --- /dev/null +++ b/src/providers/plugins/azure-openai/azure-openai.models.ts @@ -0,0 +1,111 @@ +/** + * Azure OpenAI Model Proxy + * + * Fetches available deployments from Azure OpenAI via the OpenAI-compatible endpoint. + */ + +import type { CodeMieConfigOptions } from '../../../env/types.js'; +import type { ModelInfo, ProviderModelFetcher } from '../../core/types.js'; +import { ProviderRegistry } from '../../core/registry.js'; +import { ConfigurationError } from '../../../utils/errors.js'; + +export interface AzureOpenAIDeploymentInfo { + id: string; + name: string; + description?: string; + model?: string; +} + +/** + * Extension of ProviderModelFetcher that also supports fetching raw deployment info. + * Implemented by AzureOpenAIModelProxy and accessible via ProviderRegistry.getModelProxy('azure-openai'). + */ +export interface AzureDeploymentFetcher { + fetchDeploymentInfos(config: CodeMieConfigOptions): Promise; +} + +export class AzureOpenAIModelProxy implements ProviderModelFetcher { + constructor( + private baseUrl: string, + private apiKey?: string, + private apiVersion: string = '2025-04-01-preview' + ) {} + + supports(provider: string): boolean { + return provider === 'azure-openai'; + } + + private buildDeploymentsUrl(endpoint: string, apiVersion: string): string { + const target = new URL(endpoint); + const normalizedPath = target.pathname.replace(/\/+$/, ''); + const rootSuffix = ['/openai/v1', '/openai', '/v1'] + .find(suffix => normalizedPath.endsWith(suffix)); + const rootPath = rootSuffix + ? normalizedPath.slice(0, -rootSuffix.length) + : normalizedPath; + target.pathname = `${rootPath}/openai/deployments`; + target.search = ''; + target.searchParams.set('api-version', apiVersion); + return target.toString(); + } + + async fetchDeploymentInfos(config: CodeMieConfigOptions): Promise { + // baseUrl is always set by buildConfig; no legacy azureOpenAIBaseUrl fallback needed. + const endpoint = config.baseUrl || this.baseUrl; + const apiKey = config.apiKey || this.apiKey; + const apiVersion = config.azureApiVersion || this.apiVersion; + + if (!endpoint) { + return []; + } + + const response = await fetch(this.buildDeploymentsUrl(endpoint, apiVersion), { + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'api-key': apiKey } : {}) + } + }); + + if (!response.ok) { + throw new ConfigurationError(`Failed to fetch Azure OpenAI deployments: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { data?: Array> }; + const deployments = data.data ?? []; + + return deployments + .map((deployment): AzureOpenAIDeploymentInfo | null => { + const id = String(deployment.id || deployment.name || deployment.model || '').trim(); + if (!id) { + return null; + } + + const name = String(deployment.name || deployment.id || id).trim(); + const description = typeof deployment.model === 'string' ? `Model: ${deployment.model}` : undefined; + + return { + id, + name, + description, + model: typeof deployment.model === 'string' ? deployment.model : undefined + }; + }) + .filter((deployment): deployment is AzureOpenAIDeploymentInfo => deployment !== null) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + async fetchModels(config: CodeMieConfigOptions): Promise { + const deployments = await this.fetchDeploymentInfos(config); + return deployments.map((deployment) => ({ + id: deployment.id, + name: deployment.name, + description: deployment.description, + metadata: { + deploymentName: deployment.name, + model: deployment.model + } + })); + } +} + +ProviderRegistry.registerModelProxy('azure-openai', new AzureOpenAIModelProxy('')); diff --git a/src/providers/plugins/azure-openai/azure-openai.setup-steps.ts b/src/providers/plugins/azure-openai/azure-openai.setup-steps.ts new file mode 100644 index 000000000..5051bb336 --- /dev/null +++ b/src/providers/plugins/azure-openai/azure-openai.setup-steps.ts @@ -0,0 +1,147 @@ +/** + * Azure OpenAI Setup Steps + * + * Interactive setup flow for Azure OpenAI provider. + */ + +import inquirer from 'inquirer'; +import type { CodeMieConfigOptions } from '../../../env/types.js'; +import type { ProviderCredentials, ProviderSetupSteps, ValidationResult } from '../../core/types.js'; +import { AzureOpenAITemplate } from './azure-openai.template.js'; +import { AzureOpenAIModelProxy } from './azure-openai.models.js'; + +const FALLBACK_AZURE_MODEL = AzureOpenAITemplate.recommendedModels[0] || 'gpt-5.6-luna-2026-07-09'; + +export const AzureOpenAISetupSteps: ProviderSetupSteps = { + name: 'azure-openai', + + async getCredentials(_isUpdate = false): Promise { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'baseUrl', + message: 'Azure OpenAI endpoint:', + default: AzureOpenAITemplate.defaultBaseUrl, + validate: (input: string) => input.trim() !== '' || 'Endpoint is required' + }, + { + type: 'password', + name: 'apiKey', + message: 'Azure OpenAI API Key:', + mask: '*', + validate: (input: string) => input.trim() !== '' || 'API key is required' + }, + { + type: 'input', + name: 'azureApiVersion', + message: 'Azure OpenAI API version:', + default: '2025-04-01-preview', + validate: (input: string) => input.trim() !== '' || 'API version is required' + } + ]); + + return { + baseUrl: answers.baseUrl.trim(), + apiKey: answers.apiKey.trim(), + additionalConfig: { + azureApiVersion: answers.azureApiVersion.trim() + } + }; + }, + + async fetchModels(credentials: ProviderCredentials): Promise { + const modelProxy = new AzureOpenAIModelProxy( + credentials.baseUrl || AzureOpenAITemplate.defaultBaseUrl, + credentials.apiKey, + credentials.additionalConfig?.azureApiVersion as string | undefined + ); + + try { + const deployments = await modelProxy.fetchDeploymentInfos({ + provider: 'azure-openai', + baseUrl: credentials.baseUrl || AzureOpenAITemplate.defaultBaseUrl, + apiKey: credentials.apiKey, + model: 'temp', + timeout: 300, + azureApiVersion: credentials.additionalConfig?.azureApiVersion as string | undefined + } as CodeMieConfigOptions); + + return deployments.map(deployment => deployment.id); + } catch { + return AzureOpenAITemplate.recommendedModels.length > 0 + ? AzureOpenAITemplate.recommendedModels + : [FALLBACK_AZURE_MODEL]; + } + }, + + async selectModel(credentials: ProviderCredentials, _models: string[], _template?: typeof AzureOpenAITemplate): Promise { + const modelProxy = new AzureOpenAIModelProxy( + credentials.baseUrl || AzureOpenAITemplate.defaultBaseUrl, + credentials.apiKey, + credentials.additionalConfig?.azureApiVersion as string | undefined + ); + + try { + const deployments = await modelProxy.fetchDeploymentInfos({ + provider: 'azure-openai', + baseUrl: credentials.baseUrl || AzureOpenAITemplate.defaultBaseUrl, + apiKey: credentials.apiKey, + model: 'temp', + timeout: 300, + azureApiVersion: credentials.additionalConfig?.azureApiVersion as string | undefined + } as CodeMieConfigOptions); + + if (deployments.length === 0) { + return null; + } + + const deploymentChoices = deployments.map((deployment) => ({ + name: deployment.description + ? `${deployment.name} — ${deployment.description}` + : deployment.name, + value: deployment.id + })); + + const { selectedDeployment } = await inquirer.prompt([ + { + type: 'list', + name: 'selectedDeployment', + message: 'Select Azure OpenAI deployment:', + choices: deploymentChoices, + pageSize: 15 + } + ]); + + return selectedDeployment; + } catch { + return null; + } + }, + + buildConfig(credentials: ProviderCredentials, selectedModel: string): Partial { + return { + provider: 'azure-openai', + baseUrl: credentials.baseUrl, + apiKey: credentials.apiKey, + model: selectedModel, + azureDeployment: selectedModel, + azureApiVersion: credentials.additionalConfig?.azureApiVersion as string | undefined + }; + }, + + async validate(config: Partial): Promise { + if (!config.baseUrl) { + return { valid: false, errors: ['Azure OpenAI endpoint is required'] }; + } + + if (!config.apiKey) { + return { valid: false, errors: ['Azure OpenAI API key is required'] }; + } + + if (!config.azureApiVersion) { + return { valid: false, errors: ['Azure OpenAI API version is required'] }; + } + + return { valid: true }; + } +}; diff --git a/src/providers/plugins/azure-openai/azure-openai.template.ts b/src/providers/plugins/azure-openai/azure-openai.template.ts new file mode 100644 index 000000000..b65c1d125 --- /dev/null +++ b/src/providers/plugins/azure-openai/azure-openai.template.ts @@ -0,0 +1,126 @@ +/** + * Azure OpenAI Provider Template + * + * Template definition for Azure OpenAI. + * Auto-registers on import via registerProvider(). + * + * Key architecture notes for Azure OpenAI: + * + * 1. AUTH HEADER: Azure OpenAI uses `api-key: {key}` header, + * NOT `Authorization: Bearer {key}`. @ai-sdk/openai-compatible always sends + * the Bearer header when apiKey is set, so we pass apiKey='' and inject the + * correct header explicitly via `headers: { 'api-key': key }`. + * + * 2. URL ROUTING: Azure routes to a specific deployment via: + * /openai/deployments/{deployment}/chat/completions?api-version={ver} + * @ai-sdk/openai-compatible appends /chat/completions to baseURL, so + * baseURL must be: {endpoint}/openai/deployments/{deployment}/ + * + * 3. Environment variable flow for generic OpenAI-compatible clients: + * Config → exportEnvVars → CODEMIE_AZURE_OPENAI_BASE_URL / CODEMIE_API_KEY / CODEMIE_MODEL + * agentHooks['*'].beforeRun → AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, + * AZURE_OPENAI_DEPLOYMENT and AZURE_OPENAI_API_KEY + * + * 4. Protocol scope: Claude Code/ACP, Codex and Gemini are intentionally excluded + * from direct Azure provider metadata. Azure-backed runs for those clients should + * use an external LiteLLM gateway for protocol conversion. + */ + +import type { ProviderTemplate } from '../../core/types.js'; +import { registerProvider } from '../../core/decorators.js'; + +const DEFAULT_AZURE_API_VERSION = '2025-04-01-preview'; + +export const AzureOpenAITemplate = registerProvider({ + name: 'azure-openai', + displayName: 'Azure OpenAI', + description: 'Microsoft Azure — Chat Completions API access to available deployments', + defaultBaseUrl: 'https://YOUR-RESOURCE-NAME.openai.azure.com', + requiresAuth: true, + authType: 'api-key', + priority: 13, + defaultProfileName: 'azure-openai', + // Models are fetched dynamically from the Azure deployments API during setup. + // These are shown only as a fallback when the API call fails. + recommendedModels: ['gpt-5.6-luna-2026-07-09'], + capabilities: ['streaming', 'tools', 'function-calling', 'vision', 'json-mode'], + supportsModelInstallation: false, + supportsStreaming: true, + + // Export Azure-specific fields as CODEMIE_AZURE_OPENAI_* env vars. + // The standard CODEMIE_BASE_URL / CODEMIE_API_KEY / CODEMIE_MODEL are set by + // ConfigLoader.exportProviderEnvVars automatically from config.baseUrl / apiKey / model. + exportEnvVars: (config) => { + const env: Record = {}; + + // Mirror baseUrl into a dedicated Azure var so agent hooks can distinguish it + // from the generic proxy URL that SSO providers put in CODEMIE_BASE_URL. + if (config.baseUrl) env.CODEMIE_AZURE_OPENAI_BASE_URL = config.baseUrl; + if (config.azureApiVersion) env.CODEMIE_AZURE_OPENAI_API_VERSION = config.azureApiVersion; + // Deployment name (= model by default, may differ if user set azureDeployment explicitly) + if (config.azureDeployment) env.CODEMIE_AZURE_OPENAI_DEPLOYMENT = config.azureDeployment; + + return env; + }, + + agentHooks: { + // Wildcard hook: runs for ALL agents before the agent-specific hook. + // Sets the standard Azure SDK env vars used by OpenAI-compatible clients. + '*': { + beforeRun: async (env) => { + // Azure endpoint; when proxy is active, use the local proxy URL. + const endpoint = env.CODEMIE_PROXY_ACTIVE === '1' + ? env.CODEMIE_BASE_URL + : env.CODEMIE_AZURE_OPENAI_BASE_URL || env.CODEMIE_BASE_URL; + if (endpoint) { + env.AZURE_OPENAI_ENDPOINT = endpoint; + } + + // API version + env.AZURE_OPENAI_API_VERSION = + env.CODEMIE_AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION; + + // Deployment name (falls back to model id — valid for most Azure setups + // where the deployment name matches the base model name) + env.AZURE_OPENAI_DEPLOYMENT = + env.CODEMIE_AZURE_OPENAI_DEPLOYMENT || env.CODEMIE_MODEL || ''; + + // Azure API key for generic SDK usage + if (env.CODEMIE_API_KEY) { + env.AZURE_OPENAI_API_KEY = env.CODEMIE_API_KEY; + } + + return env; + } + } + }, + + setupInstructions: ` +# Azure OpenAI Setup Instructions + +## Prerequisites + +1. Azure subscription with Azure OpenAI access +2. An Azure OpenAI resource +3. At least one deployed model in Azure OpenAI Studio + +## Required Settings + +- **Endpoint**: https://.openai.azure.com +- **API Key**: Azure OpenAI key +- **API Version**: 2025-04-01-preview (or another supported Azure API version) +- **Deployment Name**: Azure deployment identifier + +## Using CodeMie with Azure OpenAI + +\`\`\`bash +codemie setup +# Select "Azure OpenAI" as provider +\`\`\` + +## Documentation + +- Azure OpenAI: https://learn.microsoft.com/azure/ai-services/openai/ +- Quotas and limits: https://learn.microsoft.com/azure/ai-services/openai/quotas-limits +` +}); diff --git a/src/providers/plugins/azure-openai/index.ts b/src/providers/plugins/azure-openai/index.ts new file mode 100644 index 000000000..ba45f1937 --- /dev/null +++ b/src/providers/plugins/azure-openai/index.ts @@ -0,0 +1,17 @@ +/** + * Azure OpenAI Provider - Complete Provider Implementation + * + * Auto-registers with ProviderRegistry on import. + */ + +export { AzureOpenAITemplate } from './azure-openai.template.js'; +export { AzureOpenAISetupSteps } from './azure-openai.setup-steps.js'; +export { AzureOpenAIModelProxy } from './azure-openai.models.js'; +export { AzureOpenAIHealthCheck } from './azure-openai.health.js'; + +// Auto-register setup steps +import { ProviderRegistry } from '../../core/registry.js'; +import { AzureOpenAITemplate } from './azure-openai.template.js'; +import { AzureOpenAISetupSteps } from './azure-openai.setup-steps.js'; + +ProviderRegistry.registerProviderSetup(AzureOpenAITemplate, AzureOpenAISetupSteps); diff --git a/src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-routing.plugin.test.ts b/src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-routing.plugin.test.ts new file mode 100644 index 000000000..0a8459423 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-routing.plugin.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { logger } from '../../../../../../utils/logger.js'; +import type { ProxyContext } from '../../proxy-types.js'; +import type { PluginContext, ProxyInterceptor } from '../types.js'; +import { AzureOpenAIRoutingPlugin } from '../azure-openai-routing.plugin.js'; + +function createPluginContext(provider = 'azure-openai'): PluginContext { + return { + config: { + targetApiUrl: 'https://resource.example.test/code-assistant-api', + provider, + clientType: 'codemie-code', + model: 'profile-deployment', + }, + logger, + profileConfig: { + provider: 'azure-openai', + baseUrl: 'https://resource.example.test/code-assistant-api', + apiKey: 'PLACEHOLDER-KEY-FOR-TESTING-ONLY', + model: 'profile-deployment', + azureApiVersion: '2025-04-01-preview', + }, + }; +} + +function createRequest( + url: string, + model?: string, + headers: Record = { + authorization: 'Bearer proxy-token', + 'content-type': 'application/json', + }, +): ProxyContext { + return { + requestId: 'request-id', + sessionId: 'session-id', + agentName: 'codemie-code', + method: model ? 'POST' : 'GET', + url, + headers, + requestBody: model ? Buffer.from(JSON.stringify({ model }), 'utf-8') : null, + requestStartTime: Date.now(), + metadata: {}, + }; +} + +async function createInterceptor(provider = 'azure-openai'): Promise { + return new AzureOpenAIRoutingPlugin().createInterceptor(createPluginContext(provider)); +} + +describe('AzureOpenAIRoutingPlugin', () => { + it('routes chat requests to the selected deployment and preserves endpoint prefix', async () => { + const interceptor = await createInterceptor(); + const context = createRequest('/v1/chat/completions?stream=true', 'deployment-b'); + + await interceptor.onRequest?.(context); + + expect(context.targetUrl).toBe( + 'https://resource.example.test/code-assistant-api/openai/deployments/deployment-b/chat/completions?stream=true&api-version=2025-04-01-preview', + ); + expect(context.headers['api-key']).toBe('PLACEHOLDER-KEY-FOR-TESTING-ONLY'); + expect(context.headers.authorization).toBeUndefined(); + }); + + it('uses the profile deployment when the request does not contain a model', async () => { + const interceptor = await createInterceptor(); + const context = createRequest('/v1/chat/completions', undefined, { + 'content-type': 'application/json', + }); + + await interceptor.onRequest?.(context); + + expect(context.targetUrl).toBe( + 'https://resource.example.test/code-assistant-api/openai/deployments/profile-deployment/chat/completions?api-version=2025-04-01-preview', + ); + }); + + it('routes model discovery to the classic Azure deployments endpoint', async () => { + const interceptor = await createInterceptor(); + const context = createRequest('/v1/models?limit=10'); + + await interceptor.onRequest?.(context); + + expect(context.targetUrl).toBe( + 'https://resource.example.test/code-assistant-api/openai/deployments?limit=10&api-version=2025-04-01-preview', + ); + }); + + it('does not activate for another provider', async () => { + const interceptor = await createInterceptor('litellm'); + const context = createRequest('/v1/chat/completions', 'deployment-b'); + + await interceptor.onRequest?.(context); + + expect(context.targetUrl).toBeUndefined(); + expect(context.headers.authorization).toBe('Bearer proxy-token'); + }); +}); diff --git a/src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-sanitizer.plugin.test.ts b/src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-sanitizer.plugin.test.ts new file mode 100644 index 000000000..6f4642e34 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/__tests__/azure-openai-sanitizer.plugin.test.ts @@ -0,0 +1,271 @@ +/** + * Azure OpenAI proxy sanitizer tests. + * + * Covers provider activation and request-body transformations performed by the + * proxy-level interceptor. The old OpenCode source-string tests were moved here + * because sanitization now runs in CodeMieProxy before forwarding requests. + * + * @group unit + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AzureOpenAISanitizerPlugin } from '../azure-openai-sanitizer.plugin.js'; +import type { PluginContext, ProxyInterceptor } from '../types.js'; +import type { ProxyContext } from '../../proxy-types.js'; +import { logger } from '../../../../../../utils/logger.js'; + +function createPluginContext(provider = 'azure-openai'): PluginContext { + return { + config: { + targetApiUrl: 'https://api.example.com', + provider, + clientType: 'codemie-code', + sessionId: 'test-session', + }, + logger, + }; +} + +function createProxyContext( + body: Record | null, + contentType = 'application/json', + url = '/v1/chat/completions', +): ProxyContext { + const requestBody = body ? Buffer.from(JSON.stringify(body), 'utf-8') : null; + return { + requestId: 'test-request', + sessionId: 'test-session', + agentName: 'codemie-code', + method: 'POST', + url, + headers: { + 'content-type': contentType, + ...(requestBody && { 'content-length': String(requestBody.length) }), + }, + requestBody, + requestStartTime: Date.now(), + metadata: {}, + }; +} + +function readBody(context: ProxyContext): Record { + return JSON.parse(context.requestBody!.toString('utf-8')) as Record; +} + +describe('AzureOpenAISanitizerPlugin', () => { + let plugin: AzureOpenAISanitizerPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + plugin = new AzureOpenAISanitizerPlugin(); + }); + + describe('plugin metadata and activation', () => { + it('has Azure-specific metadata and request transformation priority', () => { + expect(plugin.id).toBe('@codemie/proxy-azure-openai-sanitizer'); + expect(plugin.name).toBe('Azure OpenAI Sanitizer'); + expect(plugin.version).toBe('1.0.0'); + expect(plugin.priority).toBe(15); + }); + + it('creates an active interceptor for Azure OpenAI traffic', async () => { + const interceptor = await plugin.createInterceptor(createPluginContext()); + + expect(interceptor.name).toBe('azure-openai-sanitizer'); + expect(interceptor.onRequest).toBeDefined(); + }); + + it('creates a no-op interceptor for other providers', async () => { + const interceptor = await plugin.createInterceptor(createPluginContext('openai')); + + expect(interceptor.name).toBe('azure-openai-sanitizer'); + expect(interceptor.onRequest).toBeUndefined(); + }); + }); + + describe('request sanitization', () => { + let interceptor: ProxyInterceptor; + + beforeEach(async () => { + interceptor = await plugin.createInterceptor(createPluginContext()); + }); + + it('removes unsupported top-level and options fields while preserving supported values', async () => { + const context = createProxyContext({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + temperature: 0.5, + reasoningSummary: 'auto', + reasoning_summary: 'auto', + reasoning: { effort: 'high' }, + reasoning_effort: 'high', + include_reasoning: true, + reasoning_content: 'hidden', + thinking: { type: 'enabled' }, + cache_control: { type: 'ephemeral' }, + parallel_tool_calls: true, + store: true, + metadata: { trace: 'hidden' }, + prediction: { type: 'content' }, + options: { + thinking: { type: 'enabled' }, + reasoning: { effort: 'high' }, + cache_control: { type: 'ephemeral' }, + timeout: 10, + }, + }); + + await interceptor.onRequest!(context); + + const body = readBody(context); + for (const field of [ + 'reasoningSummary', + 'reasoning_summary', + 'reasoning', + 'reasoning_effort', + 'include_reasoning', + 'reasoning_content', + 'thinking', + 'cache_control', + 'parallel_tool_calls', + 'store', + 'metadata', + 'prediction', + ]) { + expect(body[field]).toBeUndefined(); + } + expect(body.model).toBe('gpt-5'); + expect(body.temperature).toBe(0.5); + expect(body.options).toEqual({ timeout: 10 }); + }); + + it('normalizes message fields and recursively removes unsupported nested fields', async () => { + const context = createProxyContext({ + messages: [{ + role: 'user', + content: [{ + type: 'text', + text: 'hello', + cache_control: { type: 'ephemeral' }, + reasoning_content: 'hidden', + thinking: { budget_tokens: 1024 }, + nested: { citations: ['hidden'], value: 'kept' }, + }], + name: 'user-name', + cache_control: { type: 'ephemeral' }, + reasoning_content: 'hidden', + thinking: { type: 'enabled' }, + unsupported: 'removed', + }], + }); + + await interceptor.onRequest!(context); + + const body = readBody(context); + expect(body.messages).toEqual([{ + role: 'user', + content: [{ + type: 'text', + text: 'hello', + nested: { value: 'kept' }, + }], + name: 'user-name', + }]); + }); + + it('normalizes tool calls and function fields', async () => { + const context = createProxyContext({ + messages: [{ + role: 'assistant', + tool_calls: [{ + id: 'call-1', + type: 'function', + function: { + name: 'get_weather', + arguments: '{}', + description: 'removed', + }, + cache_control: { type: 'ephemeral' }, + extra: 'removed', + }], + }], + }); + + await interceptor.onRequest!(context); + + const body = readBody(context); + expect(body.messages[0].tool_calls).toEqual([{ + id: 'call-1', + type: 'function', + function: { + name: 'get_weather', + arguments: '{}', + }, + }]); + }); + + it('does not rewrite a clean request body', async () => { + const context = createProxyContext({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + temperature: 0.7, + }); + const originalBody = context.requestBody!.toString('utf-8'); + const originalLength = context.headers['content-length']; + + await interceptor.onRequest!(context); + + expect(context.requestBody!.toString('utf-8')).toBe(originalBody); + expect(context.headers['content-length']).toBe(originalLength); + }); + }); + + describe('proxy request edge cases', () => { + let interceptor: ProxyInterceptor; + + beforeEach(async () => { + interceptor = await plugin.createInterceptor(createPluginContext()); + }); + + it('updates content-length after rewriting the body', async () => { + const context = createProxyContext({ + model: 'gpt-5', + reasoningSummary: 'auto', + }); + const originalLength = Number(context.headers['content-length']); + + await interceptor.onRequest!(context); + + expect(Number(context.headers['content-length'])).toBeLessThan(originalLength); + expect(Number(context.headers['content-length'])).toBe(context.requestBody!.length); + }); + + it('passes through null bodies and non-JSON content', async () => { + const emptyContext = createProxyContext(null); + await interceptor.onRequest!(emptyContext); + expect(emptyContext.requestBody).toBeNull(); + + const textContext = createProxyContext({ reasoningSummary: 'auto' }, 'text/plain'); + const originalBody = textContext.requestBody!.toString('utf-8'); + await interceptor.onRequest!(textContext); + expect(textContext.requestBody!.toString('utf-8')).toBe(originalBody); + }); + + it('passes through malformed JSON without throwing', async () => { + const context: ProxyContext = { + requestId: 'test-request', + sessionId: 'test-session', + agentName: 'codemie-code', + method: 'POST', + url: '/v1/chat/completions', + headers: { 'content-type': 'application/json' }, + requestBody: Buffer.from('not valid json{{{', 'utf-8'), + requestStartTime: Date.now(), + metadata: {}, + }; + + await expect(interceptor.onRequest!(context)).resolves.toBeUndefined(); + expect(context.requestBody!.toString('utf-8')).toBe('not valid json{{{'); + }); + }); +}); diff --git a/src/providers/plugins/sso/proxy/plugins/azure-openai-routing.plugin.ts b/src/providers/plugins/sso/proxy/plugins/azure-openai-routing.plugin.ts new file mode 100644 index 000000000..4d2a3e1a3 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/azure-openai-routing.plugin.ts @@ -0,0 +1,215 @@ +/** + * Azure OpenAI request routing. + * + * Converts OpenAI-compatible request paths into classic Azure deployment URLs + * without requiring one provider configuration entry per deployment. + */ + +import type { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; +import type { ProxyContext } from '../proxy-types.js'; +import { ConfigurationError } from '../../../../../utils/errors.js'; + +const AZURE_OPENAI_PROVIDER = 'azure-openai'; +const DEFAULT_AZURE_API_VERSION = '2025-04-01-preview'; +const LOCAL_URL_BASE = 'http://127.0.0.1'; +const NATIVE_CLAUDE_CLIENTS = new Set(['codemie-claude', 'codemie-claude-acp']); + +const SUPPORTED_OPERATIONS = new Set([ + 'chat/completions', + 'completions', + 'embeddings', + 'images/generations', + 'audio/transcriptions', + 'audio/speech', +]); + +interface AzureRoutingConfig { + endpoint: string; + apiKey?: string; + apiVersion: string; + deployment?: string; +} + +interface AzureRequestRoute { + operation?: string; + pathDeployment?: string; + isModelList: boolean; +} + +export class AzureOpenAIRoutingPlugin implements ProxyPlugin { + id = '@codemie/proxy-azure-openai-routing'; + name = 'Azure OpenAI Routing'; + version = '1.0.0'; + priority = 13; + + async createInterceptor(context: PluginContext): Promise { + if ( + context.config.provider !== AZURE_OPENAI_PROVIDER + || NATIVE_CLAUDE_CLIENTS.has(context.config.clientType || '') + ) { + return new NoOpInterceptor('azure-openai-routing'); + } + + const profile = context.profileConfig; + const endpoint = profile?.baseUrl || context.config.targetApiUrl; + const config: AzureRoutingConfig = { + endpoint, + apiKey: profile?.apiKey, + apiVersion: profile?.azureApiVersion || DEFAULT_AZURE_API_VERSION, + deployment: profile?.azureDeployment || context.config.model, + }; + + return new AzureOpenAIRoutingInterceptor(config); + } +} + +class NoOpInterceptor implements ProxyInterceptor { + constructor(public name: string) {} +} + +class AzureOpenAIRoutingInterceptor implements ProxyInterceptor { + name = 'azure-openai-routing'; + + constructor(private readonly config: AzureRoutingConfig) {} + + async onRequest(context: ProxyContext): Promise { + const requestUrl = new URL(context.url, LOCAL_URL_BASE); + const route = resolveRoute(requestUrl.pathname); + + if (route.isModelList) { + context.targetUrl = buildModelListUrl(this.config, requestUrl); + applyAzureHeaders(context, this.config); + return; + } + + if (!route.operation) { + return; + } + + const requestModel = readRequestModel(context.requestBody); + const deployment = route.pathDeployment || requestModel || this.config.deployment; + if (!deployment) { + throw new ConfigurationError( + 'Azure OpenAI deployment is missing. Configure azureDeployment or provide model in the request.' + ); + } + + context.targetUrl = buildDeploymentUrl(this.config, route.operation, deployment, requestUrl); + context.metadata.azureDeployment = deployment; + applyAzureHeaders(context, this.config); + } +} + +function resolveRoute(pathname: string): AzureRequestRoute { + const normalized = pathname.replace(/^\/+/, '').replace(/\/+$/, ''); + const deploymentMatch = normalized.match(/^openai\/deployments\/([^/]+)\/(.+)$/i); + + if (deploymentMatch) { + const operation = normalizeOperation(deploymentMatch[2]); + return { + operation, + pathDeployment: decodeURIComponent(deploymentMatch[1]), + isModelList: false, + }; + } + + if (isModelListPath(normalized)) { + return { isModelList: true }; + } + + return { + operation: normalizeOperation(normalized), + isModelList: false, + }; +} + +function normalizeOperation(pathname: string): string | undefined { + const operation = pathname.replace(/^openai\/v1\//i, '').replace(/^v1\//i, ''); + return SUPPORTED_OPERATIONS.has(operation) ? operation : undefined; +} + +function isModelListPath(pathname: string): boolean { + return /^(?:openai\/)?(?:v1\/)?models$/i.test(pathname) + || /^openai\/(?:models|deployments)$/i.test(pathname); +} + +function readRequestModel(requestBody: Buffer | null): string | undefined { + if (!requestBody) return undefined; + + try { + const parsed = JSON.parse(requestBody.toString('utf-8')) as { model?: unknown }; + if (typeof parsed.model !== 'string') return undefined; + + const model = parsed.model.trim(); + if (!model) return undefined; + if (model.toLowerCase().startsWith(`${AZURE_OPENAI_PROVIDER}/`)) { + return model.slice(AZURE_OPENAI_PROVIDER.length + 1); + } + return model; + } catch { + return undefined; + } +} + +function buildDeploymentUrl( + config: AzureRoutingConfig, + operation: string, + deployment: string, + requestUrl: URL, +): string { + const target = createEndpointUrl(config.endpoint); + target.pathname = `${getEndpointRoot(target.pathname)}/openai/deployments/${encodeURIComponent(deployment)}/${operation}`; + copyQuery(requestUrl, target); + // DIAL uses the classic Azure API; send the dated api-version in both header and query. + target.searchParams.set('api-version', config.apiVersion); + return target.toString(); +} + +function buildModelListUrl(config: AzureRoutingConfig, requestUrl: URL): string { + const target = createEndpointUrl(config.endpoint); + target.pathname = `${getEndpointRoot(target.pathname)}/openai/deployments`; + copyQuery(requestUrl, target); + target.searchParams.set('api-version', config.apiVersion); + return target.toString(); +} + +function createEndpointUrl(endpoint: string): URL { + let target: URL; + try { + target = new URL(endpoint); + } catch { + throw new ConfigurationError(`Invalid Azure OpenAI endpoint: ${endpoint}`); + } + + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + throw new ConfigurationError('Azure OpenAI endpoint must use http or https.'); + } + + return target; +} + +function getEndpointRoot(pathname: string): string { + const normalized = pathname.replace(/\/+$/, ''); + for (const suffix of ['/openai/v1', '/openai', '/v1']) { + if (normalized.endsWith(suffix)) { + return normalized.slice(0, -suffix.length); + } + } + return normalized; +} + +function copyQuery(source: URL, target: URL): void { + for (const [key, value] of source.searchParams) { + target.searchParams.set(key, value); + } +} + +function applyAzureHeaders(context: ProxyContext, config: AzureRoutingConfig): void { + if (config.apiKey) { + context.headers['api-key'] = config.apiKey; + delete context.headers.authorization; + delete context.headers.Authorization; + } + + context.headers['api-version'] = config.apiVersion; +} diff --git a/src/providers/plugins/sso/proxy/plugins/azure-openai-sanitizer.plugin.ts b/src/providers/plugins/sso/proxy/plugins/azure-openai-sanitizer.plugin.ts new file mode 100644 index 000000000..d90059fd7 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/azure-openai-sanitizer.plugin.ts @@ -0,0 +1,250 @@ +/** + * Azure OpenAI Request Sanitizer + * Priority: 15 (request-body transformation stage) + * + * Azure OpenAI exposes an OpenAI-compatible Chat Completions schema. This + * proxy plugin preserves the existing Azure sanitizer behavior for requests + * that are routed through CodeMieProxy: provider-specific fields are removed + * from the request root, messages, content items, and tool calls. + */ + +import type { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; +import type { ProxyContext } from '../proxy-types.js'; +import { logger } from '../../../../../utils/logger.js'; + +const AZURE_OPENAI_PROVIDER = 'azure-openai'; + +const UNSUPPORTED_TOP_LEVEL_FIELDS = [ + 'reasoningSummary', + 'reasoning_summary', + 'reasoning', + 'reasoning_effort', + 'include_reasoning', + 'reasoning_content', + 'thinking', + 'cache_control', + 'betas', + 'anthropic_beta', + 'anthropic_version', + 'store', + 'metadata', + 'prediction', + 'modalities', + 'service_tier', + 'parallel_tool_calls', + 'prompt_cache_key', +] as const; + +const UNSUPPORTED_NESTED_FIELDS = [ + 'cache_control', + 'reasoning_content', + 'reasoningContent', + 'thinking', + 'citations', + 'signature', + 'redacted_thinking', +] as const; + +const ALLOWED_MESSAGE_FIELDS = new Set([ + 'role', + 'content', + 'name', + 'tool_call_id', + 'tool_calls', + 'function_call', +]); + +const ALLOWED_TOOL_CALL_FIELDS = new Set(['id', 'type', 'function']); +const ALLOWED_FUNCTION_FIELDS = new Set(['name', 'arguments']); + +interface SanitizationResult { + value: unknown; + modified: boolean; +} + +export class AzureOpenAISanitizerPlugin implements ProxyPlugin { + id = '@codemie/proxy-azure-openai-sanitizer'; + name = 'Azure OpenAI Sanitizer'; + version = '1.0.0'; + priority = 15; + + async createInterceptor(context: PluginContext): Promise { + if (context.config.provider !== AZURE_OPENAI_PROVIDER) { + return new NoOpInterceptor('azure-openai-sanitizer'); + } + + return new AzureOpenAISanitizerInterceptor(); + } +} + +class NoOpInterceptor implements ProxyInterceptor { + constructor(public name: string) {} +} + +class AzureOpenAISanitizerInterceptor implements ProxyInterceptor { + name = 'azure-openai-sanitizer'; + + async onRequest(context: ProxyContext): Promise { + if (!context.requestBody || !context.headers['content-type']?.includes('application/json')) { + return; + } + + try { + const body = JSON.parse(context.requestBody.toString('utf-8')) as unknown; + const sanitized = sanitizeRequest(body); + + if (!sanitized.modified) { + return; + } + + context.requestBody = Buffer.from(JSON.stringify(sanitized.value), 'utf-8'); + context.headers['content-length'] = String(context.requestBody.length); + + logger.debug(`[${this.name}] Removed unsupported Azure OpenAI request fields`); + } catch { + // Not valid JSON or unexpected structure — pass through unchanged. + } + } +} + +function sanitizeRequest(value: unknown): SanitizationResult { + if (!isPlainObject(value)) { + return { value, modified: false }; + } + + const input = value as Record; + const cleaned: Record = { ...input }; + let modified = false; + + modified = removeFields(cleaned, UNSUPPORTED_TOP_LEVEL_FIELDS) || modified; + + if (Array.isArray(input.messages)) { + const messages = input.messages.map(sanitizeMessage); + cleaned.messages = messages.map(result => result.value); + modified = messages.some(result => result.modified) || modified; + } + + if (isPlainObject(input.options)) { + const options = { ...input.options }; + const optionsModified = removeFields(options, UNSUPPORTED_TOP_LEVEL_FIELDS); + cleaned.options = options; + modified = optionsModified || modified; + } + + return { value: cleaned, modified }; +} + +function sanitizeMessage(value: unknown): SanitizationResult { + if (!isPlainObject(value)) { + return { value, modified: false }; + } + + const input = value as Record; + const message: Record = {}; + let modified = false; + + for (const [key, childValue] of Object.entries(input)) { + if (!ALLOWED_MESSAGE_FIELDS.has(key)) { + modified = true; + continue; + } + + if (key === 'tool_calls' && Array.isArray(childValue)) { + const toolCalls = childValue.map(sanitizeToolCall); + message[key] = toolCalls.map(result => result.value); + modified = toolCalls.some(result => result.modified) || modified; + continue; + } + + const nested = sanitizeNested(childValue); + message[key] = nested.value; + modified = nested.modified || modified; + } + + modified = removeFields(message, [ + 'cache_control', + 'reasoning_content', + 'reasoningContent', + 'thinking', + ]) || modified; + + return { value: message, modified }; +} + +function sanitizeToolCall(value: unknown): SanitizationResult { + if (!isPlainObject(value)) { + return { value, modified: false }; + } + + const input = value as Record; + const cleaned: Record = {}; + let modified = false; + + for (const [key, childValue] of Object.entries(input)) { + if (!ALLOWED_TOOL_CALL_FIELDS.has(key)) { + modified = true; + continue; + } + + if (key === 'function' && isPlainObject(childValue)) { + const fn: Record = {}; + for (const [fnKey, fnValue] of Object.entries(childValue)) { + if (!ALLOWED_FUNCTION_FIELDS.has(fnKey)) { + modified = true; + continue; + } + fn[fnKey] = fnValue; + } + cleaned[key] = fn; + } else { + const nested = sanitizeNested(childValue); + cleaned[key] = nested.value; + modified = nested.modified || modified; + } + } + + return { value: cleaned, modified }; +} + +function sanitizeNested(value: unknown): SanitizationResult { + if (Array.isArray(value)) { + const items = value.map(sanitizeNested); + return { + value: items.map(item => item.value), + modified: items.some(item => item.modified), + }; + } + + if (!isPlainObject(value)) { + return { value, modified: false }; + } + + const cleaned: Record = { ...(value as Record) }; + let modified = removeFields(cleaned, UNSUPPORTED_NESTED_FIELDS); + + for (const [key, childValue] of Object.entries(cleaned)) { + const nested = sanitizeNested(childValue); + cleaned[key] = nested.value; + modified = nested.modified || modified; + } + + return { value: cleaned, modified }; +} + +function removeFields( + value: Record, + fields: readonly string[] +): boolean { + let modified = false; + for (const field of fields) { + if (field in value) { + delete value[field]; + modified = true; + } + } + return modified; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/providers/plugins/sso/proxy/plugins/index.ts b/src/providers/plugins/sso/proxy/plugins/index.ts index f1ab3a4a3..8c302c3d3 100644 --- a/src/providers/plugins/sso/proxy/plugins/index.ts +++ b/src/providers/plugins/sso/proxy/plugins/index.ts @@ -19,6 +19,8 @@ import { CodexRequestNormalizerPlugin } from './codex-request-normalizer.plugin. import { CodexEncryptedContentSanitizerPlugin } from './codex-encrypted-content-sanitizer.plugin.js'; import { CopilotEncryptedContentSanitizerPlugin } from './copilot-encrypted-content-sanitizer.plugin.js'; import { VsCodeRequestNormalizerPlugin } from './vscode-request-normalizer.plugin.js'; +import { AzureOpenAISanitizerPlugin } from './azure-openai-sanitizer.plugin.js'; +import { AzureOpenAIRoutingPlugin } from './azure-openai-routing.plugin.js'; import { LoggingPlugin } from './logging.plugin.js'; import { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; @@ -37,8 +39,10 @@ export function registerCorePlugins(): void { registry.register(new JWTAuthPlugin()); registry.register(new ClaudeRequestNormalizerPlugin()); // Priority 14 - normalizes thinking params for claude models registry.register(new KimiRequestNormalizerPlugin()); // Priority 14 - caps Kimi output token requests for upstream limits + registry.register(new AzureOpenAIRoutingPlugin()); // Priority 13 - resolves classic Azure deployment URLs registry.register(new CodexRequestNormalizerPlugin()); // Priority 14 - maps the Codex app's undated model names onto dated CodeMie deployments registry.register(new RequestSanitizerPlugin()); // Priority 15 - strips unsupported reasoning params + registry.register(new AzureOpenAISanitizerPlugin()); // Priority 15 - Azure OpenAI request compatibility registry.register(new CodexEncryptedContentSanitizerPlugin()); // Priority 16 - forwards Responses reasoning state; strips it only after upstream rejects a replay registry.register(new CopilotEncryptedContentSanitizerPlugin()); // Priority 16 - retries Copilot once after encrypted reasoning replay rejection registry.register(new VsCodeRequestNormalizerPlugin()); // Priority 17 - constrains VS Code user identifiers @@ -65,6 +69,8 @@ export { CodexEncryptedContentSanitizerPlugin, CopilotEncryptedContentSanitizerPlugin, VsCodeRequestNormalizerPlugin, + AzureOpenAISanitizerPlugin, + AzureOpenAIRoutingPlugin, LoggingPlugin, }; export { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; diff --git a/src/providers/plugins/sso/proxy/sso.proxy.ts b/src/providers/plugins/sso/proxy/sso.proxy.ts index f0a6183c9..01785ee81 100644 --- a/src/providers/plugins/sso/proxy/sso.proxy.ts +++ b/src/providers/plugins/sso/proxy/sso.proxy.ts @@ -300,7 +300,9 @@ export class CodeMieProxy { } // 3. Forward request to upstream - const targetUrl = this.buildTargetUrl(req.url!); + const targetUrl = context.targetUrl + ? new URL(context.targetUrl) + : this.buildTargetUrl(req.url!); context.targetUrl = targetUrl.toString(); logger.info( diff --git a/src/utils/config.ts b/src/utils/config.ts index f233d3a0e..c8d4ac8ba 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -197,6 +197,17 @@ export class ConfigLoader { Object.assign(config, this.removeUndefined(cliOverrides)); } + if (config.provider?.toLowerCase() === 'azure-openai') { + const deployment = cliOverrides?.model + || envConfig.azureDeployment + || config.azureDeployment + || config.model; + if (deployment) { + config.model = deployment; + config.azureDeployment = deployment; + } + } + return config; } @@ -424,6 +435,12 @@ export class ConfigLoader { if (process.env.CODEMIE_MODEL) { env.model = process.env.CODEMIE_MODEL; } + if (process.env.CODEMIE_AZURE_OPENAI_API_VERSION) { + env.azureApiVersion = process.env.CODEMIE_AZURE_OPENAI_API_VERSION; + } + if (process.env.CODEMIE_AZURE_OPENAI_DEPLOYMENT) { + env.azureDeployment = process.env.CODEMIE_AZURE_OPENAI_DEPLOYMENT; + } if (process.env.CODEMIE_TIMEOUT) { env.timeout = parseInt(process.env.CODEMIE_TIMEOUT, 10); } @@ -886,7 +903,13 @@ export class ConfigLoader { const configDir = path.join(workingDir, '.codemie'); await fs.mkdir(configDir, { recursive: true }); - // Create multi-provider config structure + // Load existing local config to preserve other profiles and settings + const existingConfig = (await this.loadLocalMultiProviderConfig(workingDir).catch(() => ({ + version: 2 as const, + activeProfile: 'default', + profiles: {} + }))) as MultiProviderConfig; + const profileName = overrides?.profileName || 'default'; const rawOverrides: Partial = {}; @@ -900,9 +923,10 @@ export class ConfigLoader { const { profile, workspace } = this.splitProfileAndWorkspace(rawOverrides); const config: MultiProviderConfig = { - version: 2, + ...existingConfig, activeProfile: profileName, profiles: { + ...existingConfig.profiles, [profileName]: profile as any }, ...(Object.keys(this.removeUndefined(workspace)).length > 0 ? { workspace } : {}) @@ -1234,6 +1258,7 @@ export class ConfigLoader { const localWorkspaceScope = await this.loadLocalMultiProviderConfig(workingDir); const workspaceSource: 'project' | 'global' = localWorkspaceScope.workspace != null ? 'project' : 'global'; const workspace = await this.resolveWorkspace(workingDir); + const envConfig = this.loadFromEnv(); const configs: ConfigLayer[] = [ { @@ -1256,7 +1281,7 @@ export class ConfigLoader { source: workspaceSource }, { - data: this.loadFromEnv(), + data: envConfig, source: 'env' } ]; @@ -1281,6 +1306,14 @@ export class ConfigLoader { // Build merged config const config = await this.load(workingDir, cliOverrides); + if ( + config.provider?.toLowerCase() === 'azure-openai' + && envConfig.azureDeployment + && !cliOverrides?.model + ) { + sources.model = { value: config.model, source: 'env' }; + } + return { config, hasLocalConfig, diff --git a/src/utils/native-installer.ts b/src/utils/native-installer.ts index 8da1816bd..f9ff5be50 100644 --- a/src/utils/native-installer.ts +++ b/src/utils/native-installer.ts @@ -113,21 +113,28 @@ function buildInstallerCommand( // Build platform-specific command if (platform === 'windows') { + if (url.toLowerCase().endsWith('.ps1')) { + // PowerShell installers such as Kimi use environment variables for version + // selection rather than accepting a positional version argument. + const versionEnv = version ? `set KIMI_VERSION=${version} && ` : ''; + return `curl -fsSL ${url} -o install.ps1 && ${versionEnv}powershell.exe -NoProfile -ExecutionPolicy Bypass -File install.ps1 && del install.ps1`; + } + // Windows CMD command (simpler and more universal than PowerShell) // Download install.cmd, execute with args, then delete const versionArg = version ? ` ${version}` : ''; const flagsArg = installFlags && installFlags.length > 0 ? ` ${installFlags.join(' ')}` : ''; return `curl -fsSL ${url} -o install.cmd && install.cmd${versionArg}${flagsArg} && del install.cmd`; - } else { - // macOS/Linux shell script command - const scriptArgs = [ - ...(version ? [version] : []), - ...(installFlags || []), - ]; - const argsArg = scriptArgs.length > 0 ? ` -s -- ${scriptArgs.join(' ')}` : ''; - return `curl -fsSL ${url} | bash${argsArg}`; - } + } else { + // macOS/Linux shell script command + const scriptArgs = [ + ...(version ? [version] : []), + ...(installFlags || []), + ]; + const argsArg = scriptArgs.length > 0 ? ` -s -- ${scriptArgs.join(' ')}` : ''; + return `curl -fsSL ${url} | bash${argsArg}`; } +} /** * Verify installation by running the verify command diff --git a/src/utils/profile.ts b/src/utils/profile.ts index 489012206..da4617866 100644 --- a/src/utils/profile.ts +++ b/src/utils/profile.ts @@ -24,6 +24,7 @@ export function renderProfileInfo(config: { cliVersion?: string; sessionId?: string; isActive?: boolean; + title?: string; }): string { // Build complete output with logo and info const outputLines: string[] = []; @@ -35,6 +36,11 @@ export function renderProfileInfo(config: { return chalk.cyan(label.padEnd(13) + '│ ') + colorFn(value); }; + if (config.title) { + outputLines.push(chalk.bold.cyan(config.title)); + outputLines.push(''); + } + // Configuration details if (config.cliVersion) { outputLines.push(formatRow('CLI Version', config.cliVersion));