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));