From 2ce6e283a92fe500a95dfa799b6211c4a41310b3 Mon Sep 17 00:00:00 2001 From: zzy15 Date: Wed, 19 Nov 2025 12:52:32 +0800 Subject: [PATCH] feat: Add Azure OpenAI API support - Add Azure as 4th platform option in Electron and Web UI - Implement automatic URL parsing for Azure API endpoints - User need to paste entire azure url This change allows users to configure Azure OpenAI as an LLM provider alongside existing options (Doubao, OpenAI, Custom). --- frontend/package.json | 4 +- .../src/assets/images/settings/azure.png | Bin 0 -> 1510 bytes .../renderer/src/pages/settings/constants.tsx | 8 +- .../renderer/src/pages/settings/settings.tsx | 116 ++++++++++++- opencontext/llm/llm_client.py | 156 ++++++++++++++++-- opencontext/web/static/js/settings.js | 26 +++ opencontext/web/templates/settings.html | 28 ++++ 7 files changed, 318 insertions(+), 20 deletions(-) create mode 100644 frontend/src/renderer/src/assets/images/settings/azure.png diff --git a/frontend/package.json b/frontend/package.json index c7df5ad..ce5dbe1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -102,7 +102,7 @@ "code-inspector-plugin": "^0.20.14", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", - "electron": "^37.2.3", + "electron": "^37.10.0", "electron-builder": "^25.1.8", "electron-rebuild": "^3.2.9", "electron-vite": "^4.0.0", @@ -145,4 +145,4 @@ "esbuild" ] } -} \ No newline at end of file +} diff --git a/frontend/src/renderer/src/assets/images/settings/azure.png b/frontend/src/renderer/src/assets/images/settings/azure.png new file mode 100644 index 0000000000000000000000000000000000000000..04cb929559e8d81d7479a11216eee89877c066bf GIT binary patch literal 1510 zcmVK4SVPdmu$YO<;BF&^_Zw4PY=&hn8>xCQh@_@CF(zTE zbFb5U}*dm>mhKvuTx;qi;#3ul8^(|#mVRO|odFdEJnK9~@jUeI@sBPGVLAdT3VGIei|!S(N(yTj z!v+sl%_u43ZR78uMU}Hb8I^x&ND)v|#@ol_N01RvO+IJ1>-vWhDss92KQXQ@2vjRx*@hk&h8`JS!#6FM z>qJfZF3xO{M%YLI0RI0stB+)o;1HVZ0038Z)E#t%f8=!2xyiV+izumAxw6{;vsPf9 zyMExvUAJ?-s+%KWOY8`_fwLqkyOa*aw1ON$G)6T`57=dC@1S-3g3Wtif5*1&6;FEmUy5l(q@2nsem8eDVWw;hE6=4J_ zzW>1{`LBvbS`Xh?KrJgP3*gIeEM6+yNL79Rf=%;Y>x|?dzOaB=R-qTcmf%>r=gdCj zO1p-B>Wv>}vecWiKCY$#ihd8$4-aNcA)G4mAoVjVx&%rhZ}6+9xcw@6bayJemP}VUII^-@g+|9^|i zX*%Rm;P+K2fcFXBCb*7>%+TDr=}X@^E*Yus1;}PZ{Bn5Smn}CGMB&=rVCAObh@3lH zx-yY-YV@GYvfl7j5+WP8EjYs1h?=8(`|}4^YWJ?8ijOy0t?s=+l-{Z5>%UQJReGK^w zKPOA;JL%^^%9a;?g{}&`(EH0s3gf-z3gqnfU$5V@{~ouOm(reOunr61C^, + key: 'Azure', + value: 'azure' + }, { icon: , key: 'Custom', diff --git a/frontend/src/renderer/src/pages/settings/settings.tsx b/frontend/src/renderer/src/pages/settings/settings.tsx index 9115964..975b6f0 100644 --- a/frontend/src/renderer/src/pages/settings/settings.tsx +++ b/frontend/src/renderer/src/pages/settings/settings.tsx @@ -116,6 +116,110 @@ const CustomFormItems: FC = (props) => { ) } + +export interface AzureFormItemsProps { + prefix: string +} +const AzureFormItems: FC = (props) => { + const { prefix } = props + return ( + <> + + +
+ {/* VLM Configuration */} +
+ + Vision language model + + + } + placeholder="Deployment name" + allowClear + className="[&_.arco-input-inner-wrapper]: !w-[574px]" + /> + + + } + placeholder="Paste complete Azure URL here" + allowClear + className="[&_.arco-input-inner-wrapper]: !w-[574px]" + /> + + + Complete URL like: https://your-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-01 + + + } + placeholder="Enter your Azure OpenAI API Key" + allowClear + className="!w-[574px]" + /> + +
+ + {/* Embedding Configuration */} +
+ + Embedding model + + + } + placeholder="Embedding deployment name" + allowClear + className="!w-[574px]" + /> + + + } + placeholder="Complete Azure embedding URL or other base URL" + allowClear + className="!w-[574px]" + /> + + + } + placeholder="Enter Azure API Key" + allowClear + className="!w-[574px]" + /> + +
+
+ + ) +} + export interface StandardFormItemsProps { modelPlatform: ModelTypeList prefix: string @@ -194,7 +298,12 @@ export type SettingsFormProps = SettingsFormBase & { [K in | `${ModelTypeList.Custom}-embeddingModelId` | `${ModelTypeList.Custom}-embeddingBaseUrl` - | `${ModelTypeList.Custom}-embeddingApiKey`]?: string + | `${ModelTypeList.Custom}-embeddingApiKey` + | `${ModelTypeList.Custom}-baseUrl` + | `${ModelTypeList.Azure}-embeddingModelId` + | `${ModelTypeList.Azure}-embeddingBaseUrl` + | `${ModelTypeList.Azure}-embeddingApiKey` + | `${ModelTypeList.Azure}-baseUrl`]?: string } const Settings: FC = (props) => { const { closeSetting, init } = props @@ -221,6 +330,7 @@ const Settings: FC = (props) => { await form.validate() const values = form.getFieldsValue() const isCustom = values.modelPlatform === ModelTypeList.Custom + const isAzure = values.modelPlatform === ModelTypeList.Azure if (!values.modelPlatform) { Message.error('Please select Model Platform') return @@ -238,7 +348,7 @@ const Settings: FC = (props) => { const formatData = Object.fromEntries( Object.entries(data).map(([key, value]) => [key.replace(`${values.modelPlatform}-`, ''), value]) ) - const params = isCustom + const params = (isCustom || isAzure) ? formatData : { ...formatData, @@ -303,6 +413,8 @@ const Settings: FC = (props) => { const modelPlatform = values.modelPlatform if (modelPlatform === ModelTypeList.Custom) { return + } else if (modelPlatform === ModelTypeList.Azure) { + return } else if (modelPlatform === ModelTypeList.Doubao) { return } else if (modelPlatform === ModelTypeList.OpenAI) { diff --git a/opencontext/llm/llm_client.py b/opencontext/llm/llm_client.py index 6eec825..3f4dff7 100644 --- a/opencontext/llm/llm_client.py +++ b/opencontext/llm/llm_client.py @@ -7,10 +7,12 @@ OpenContext module: llm_client """ +import re from enum import Enum -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse, parse_qs -from openai import APIError, AsyncOpenAI, OpenAI +from openai import APIError, AsyncOpenAI, OpenAI, AzureOpenAI, AsyncAzureOpenAI from opencontext.models.context import Vectorize from opencontext.utils.logging_utils import get_logger @@ -21,6 +23,7 @@ class LLMProvider(Enum): OPENAI = "openai" DOUBAO = "doubao" + AZURE = "azure" class LLMType(Enum): @@ -34,15 +37,141 @@ def __init__(self, llm_type: LLMType, config: Dict[str, Any]): self.config = config self.model = config.get("model") self.api_key = config.get("api_key") - self.base_url = config.get("base_url") self.timeout = config.get("timeout", 300) self.provider = config.get("provider", LLMProvider.OPENAI.value) - if not self.api_key or not self.base_url or not self.model: - raise ValueError("API key, base URL, and model must be provided") - self.client = OpenAI(api_key=self.api_key, base_url=self.base_url, timeout=self.timeout) - self.async_client = AsyncOpenAI( - api_key=self.api_key, base_url=self.base_url, timeout=self.timeout - ) + + # Azure OpenAI specific initialization + if self.provider == LLMProvider.AZURE.value: + # Check if user provided base_url (which might be a complete Azure URL) + base_url = config.get("base_url") + azure_endpoint = config.get("azure_endpoint") + + # If no azure_endpoint but has base_url, try to parse it + if not azure_endpoint and base_url: + parsed_azure = self._parse_azure_url(base_url) + azure_endpoint = parsed_azure['azure_endpoint'] + + # If URL contains model and user didn't provide one, use parsed model + if parsed_azure['model'] and not self.model: + self.model = parsed_azure['model'] + logger.info(f"Extracted model from Azure URL: {self.model}") + + # If URL contains api_version, use it + if parsed_azure['api_version']: + self.api_version = parsed_azure['api_version'] + logger.info(f"Extracted api_version from Azure URL: {self.api_version}") + else: + # No api_version in URL, check config + self.api_version = config.get("api_version") + else: + # User provided azure_endpoint (standard way) + self.api_version = config.get("api_version") + + self.azure_endpoint = azure_endpoint + + # Validate required parameters + if not self.api_key: + raise ValueError( + "Azure OpenAI requires api_key. " + "Please provide your Azure OpenAI API key." + ) + + if not self.azure_endpoint: + raise ValueError( + "Azure OpenAI requires azure_endpoint or a full URL in base_url. " + "Example: https://your-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-01" + ) + + if not self.model: + raise ValueError( + "Azure OpenAI requires model (deployment name). " + "Please provide it explicitly or include it in the URL." + ) + + # Validate api_version must exist + if not self.api_version: + raise ValueError( + "Azure OpenAI requires api_version. " + "Please provide a full Azure API URL with api-version parameter. " + "Example: https://your-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-01" + ) + + self.client = AzureOpenAI( + api_key=self.api_key, + azure_endpoint=self.azure_endpoint, + api_version=self.api_version, + timeout=self.timeout + ) + self.async_client = AsyncAzureOpenAI( + api_key=self.api_key, + azure_endpoint=self.azure_endpoint, + api_version=self.api_version, + timeout=self.timeout + ) + else: + # Standard OpenAI or compatible APIs (Doubao, custom, etc.) + self.base_url = config.get("base_url") + if not self.api_key or not self.base_url or not self.model: + raise ValueError("API key, base URL, and model must be provided") + + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url, + timeout=self.timeout + ) + self.async_client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.base_url, + timeout=self.timeout + ) + + @staticmethod + def _parse_azure_url(url: str) -> Dict[str, Optional[str]]: + """ + Parse Azure OpenAI URL and extract configuration parameters + + Args: + url: Azure OpenAI URL (full URL or just endpoint) + + Returns: + Dict with keys: azure_endpoint, model, api_version + + Examples: + >>> _parse_azure_url("https://xxx.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-01") + { + 'azure_endpoint': 'https://xxx.openai.azure.com/', + 'model': 'gpt-4', + 'api_version': '2024-02-01' + } + """ + if not url or not url.strip(): + return {'azure_endpoint': None, 'model': None, 'api_version': None} + + url = url.strip() + parsed = urlparse(url) + + # Extract azure_endpoint (base domain) + azure_endpoint = f"{parsed.scheme}://{parsed.netloc}/" + + # Extract deployment name (model) from path + # Path format: /openai/deployments/{deployment-name}/... + model = None + if parsed.path: + match = re.search(r'/openai/deployments/([^/]+)', parsed.path) + if match: + model = match.group(1) + + # Extract api_version from query params + api_version = None + if parsed.query: + query_params = parse_qs(parsed.query) + api_version = query_params.get('api-version', [None])[0] + + return { + 'azure_endpoint': azure_endpoint, + 'model': model, + 'api_version': api_version + } def generate(self, prompt: str, **kwargs) -> str: messages = [{"role": "user", "content": prompt}] @@ -263,12 +392,9 @@ async def _openai_chat_completion_stream_async(self, messages: List[Dict[str, An tools = kwargs.get("tools", None) thinking = kwargs.get("thinking", None) - # Create async client - from openai import AsyncOpenAI - - async_client = AsyncOpenAI( - api_key=self.api_key, base_url=self.base_url, timeout=self.timeout - ) + # Use the existing async_client that was initialized in __init__ + # This ensures proper Azure vs OpenAI client is used + async_client = self.async_client create_params = { "model": self.model, diff --git a/opencontext/web/static/js/settings.js b/opencontext/web/static/js/settings.js index f26bfbc..52a2183 100644 --- a/opencontext/web/static/js/settings.js +++ b/opencontext/web/static/js/settings.js @@ -14,6 +14,32 @@ function showToast(message, isError = false) { toast.show(); } +// ==================== Azure 平台提示管理 ==================== + +// Platform change handler for main model +document.addEventListener('DOMContentLoaded', function() { + const modelPlatformSelect = document.getElementById('modelPlatform'); + const azureHint = document.getElementById('azureHint'); + + if (modelPlatformSelect && azureHint) { + modelPlatformSelect.addEventListener('change', function(e) { + const isAzure = e.target.value === 'azure'; + azureHint.style.display = isAzure ? 'block' : 'none'; + }); + } + + // Platform change handler for embedding model + const embeddingPlatformSelect = document.getElementById('embeddingModelPlatform'); + const embeddingAzureHint = document.getElementById('embeddingAzureHint'); + + if (embeddingPlatformSelect && embeddingAzureHint) { + embeddingPlatformSelect.addEventListener('change', function(e) { + const isAzure = e.target.value === 'azure'; + embeddingAzureHint.style.display = isAzure ? 'block' : 'none'; + }); + } +}); + // ==================== 截图捕获设置 ==================== async function loadCaptureSettings() { diff --git a/opencontext/web/templates/settings.html b/opencontext/web/templates/settings.html index 45f2a5b..6a68c8b 100644 --- a/opencontext/web/templates/settings.html +++ b/opencontext/web/templates/settings.html @@ -45,11 +45,27 @@

LLM 模型配置

+ + +
@@ -77,9 +93,21 @@

Embedding 模型配置

+ + +