KiWA001 commited on
Commit
6536eb0
Β·
1 Parent(s): f0d6d61

Add Microsoft Copilot provider - Browser-based provider using Playwright - Integrated into engine and config - Added test script and documentation

Browse files
Files changed (5) hide show
  1. KAIGUIDE.md +11 -1
  2. config.py +4 -0
  3. engine.py +6 -1
  4. providers/copilot_provider.py +243 -0
  5. test_copilot_browser.py +61 -0
KAIGUIDE.md CHANGED
@@ -68,7 +68,17 @@ Uses Playwright Chromium to interact with `gemini.google.com` as a real browser.
68
  - **Files**: `providers/gemini_provider.py`, `test_gemini_browser.py`.
69
  - **Status**: **Experimental**. Requires local Playwright environment.
70
 
71
- ### D. Search & Deep Research
 
 
 
 
 
 
 
 
 
 
72
  The API includes a search engine (`search_engine.py`) powered by DuckDuckGo (via `duckduckgo_search`).
73
  - **`/search`**: Returns raw search results.
74
  - **`/deep_research`**: Multi-step process:
 
68
  - **Files**: `providers/gemini_provider.py`, `test_gemini_browser.py`.
69
  - **Status**: **Experimental**. Requires local Playwright environment.
70
 
71
+ ### E. Microsoft Copilot (Browser-Based Provider)
72
+ Uses Playwright Chromium to interact with `copilot.microsoft.com` as a real browser.
73
+ - **Why Browser**: Microsoft's Copilot requires a browser session to function properly.
74
+ - **Input**: Multiple selector strategies for robust input detection (`[data-testid="chat-input"]`, `div[contenteditable="true"]`).
75
+ - **Features**: Handles "Continue" buttons automatically for longer responses.
76
+ - **Model**: `copilot-gpt-4` (GPT-4 powered responses).
77
+ - **Files**: `providers/copilot_provider.py`.
78
+ - **Status**: **Experimental**. Requires local Playwright environment.
79
+ - **Vercel**: **DISABLED** (no Chromium in serverless). Local/Docker only.
80
+
81
+ ### F. Search & Deep Research
82
  The API includes a search engine (`search_engine.py`) powered by DuckDuckGo (via `duckduckgo_search`).
83
  - **`/search`**: Returns raw search results.
84
  - **`/deep_research`**: Multi-step process:
config.py CHANGED
@@ -25,6 +25,7 @@ MODEL_RANKING = [
25
  ("gpt-4o-mini", "g4f", "gpt-4o-mini"),
26
  ("glm-5", "zai", "glm-5"),
27
  ("gemini-3-flash", "gemini", "gemini-3-flash"),
 
28
  ("gpt-oss-20b", "pollinations", "openai"),
29
  ("mistral-small-3.2", "pollinations", "mistral"),
30
 
@@ -81,6 +82,9 @@ PROVIDER_MODELS = {
81
  "gemini": [
82
  "gemini-3-flash",
83
  ],
 
 
 
84
  "pollinations": [
85
  "gpt-oss-20b",
86
  "mistral-small-3.2-24b",
 
25
  ("gpt-4o-mini", "g4f", "gpt-4o-mini"),
26
  ("glm-5", "zai", "glm-5"),
27
  ("gemini-3-flash", "gemini", "gemini-3-flash"),
28
+ ("copilot-gpt-4", "copilot", "copilot-gpt-4"),
29
  ("gpt-oss-20b", "pollinations", "openai"),
30
  ("mistral-small-3.2", "pollinations", "mistral"),
31
 
 
82
  "gemini": [
83
  "gemini-3-flash",
84
  ],
85
+ "copilot": [
86
+ "copilot-gpt-4",
87
+ ],
88
  "pollinations": [
89
  "gpt-oss-20b",
90
  "mistral-small-3.2-24b",
engine.py CHANGED
@@ -19,6 +19,7 @@ from providers.g4f_provider import G4FProvider
19
  from providers.pollinations_provider import PollinationsProvider
20
  from providers.gemini_provider import GeminiProvider
21
  from providers.zai_provider import ZaiProvider
 
22
  from config import MODEL_RANKING, PROVIDER_MODELS, SUPABASE_URL, SUPABASE_KEY
23
  from models import ModelInfo
24
  from sanitizer import sanitize_response
@@ -56,8 +57,12 @@ class AIEngine:
56
  # Gemini also uses Playwright, so we enable it here too
57
  self._providers["gemini"] = GeminiProvider()
58
  logger.info("βœ… Gemini provider enabled")
 
 
 
 
59
  else:
60
- logger.warning("⚠️ Z.ai/Gemini providers disabled (Playwright not installed)")
61
  # Success Tracker: Key = "provider/model_id"
62
  # Value = {success, failure, consecutive_failures, avg_time_ms, total_time_ms, count_samples}
63
  self._stats: dict[str, dict] = {}
 
19
  from providers.pollinations_provider import PollinationsProvider
20
  from providers.gemini_provider import GeminiProvider
21
  from providers.zai_provider import ZaiProvider
22
+ from providers.copilot_provider import CopilotProvider
23
  from config import MODEL_RANKING, PROVIDER_MODELS, SUPABASE_URL, SUPABASE_KEY
24
  from models import ModelInfo
25
  from sanitizer import sanitize_response
 
57
  # Gemini also uses Playwright, so we enable it here too
58
  self._providers["gemini"] = GeminiProvider()
59
  logger.info("βœ… Gemini provider enabled")
60
+
61
+ # Copilot also uses Playwright
62
+ self._providers["copilot"] = CopilotProvider()
63
+ logger.info("βœ… Copilot provider enabled")
64
  else:
65
+ logger.warning("⚠️ Z.ai/Gemini/Copilot providers disabled (Playwright not installed)")
66
  # Success Tracker: Key = "provider/model_id"
67
  # Value = {success, failure, consecutive_failures, avg_time_ms, total_time_ms, count_samples}
68
  self._stats: dict[str, dict] = {}
providers/copilot_provider.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Microsoft Copilot Provider (Browser-Based)
3
+ -------------------------------------------
4
+ Uses Playwright Chromium to interact with https://copilot.microsoft.com/ as a real browser.
5
+
6
+ Strategy:
7
+ - Reuses the global Playwright browser instance (shared pattern with Z.ai/Gemini).
8
+ - Uses EPHEMERAL contexts (Tabs) per request for robust data isolation.
9
+ - Scrapes the AI response from the DOM.
10
+ - Handles the "Continue" button for longer responses.
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import re
16
+ from providers.base import BaseProvider
17
+ from config import PROVIDER_MODELS
18
+
19
+ logger = logging.getLogger("kai_api.copilot")
20
+
21
+ _playwright = None
22
+ _browser = None
23
+ _lock = asyncio.Lock()
24
+
25
+
26
+ class CopilotProvider(BaseProvider):
27
+ """AI provider using Microsoft Copilot via Persistent Playwright Browser."""
28
+
29
+ RESPONSE_TIMEOUT = 60
30
+ HYDRATION_DELAY = 3.0
31
+
32
+ @property
33
+ def name(self) -> str:
34
+ return "copilot"
35
+
36
+ def get_available_models(self) -> list[str]:
37
+ return PROVIDER_MODELS.get("copilot", ["copilot-gpt-4"])
38
+
39
+ @staticmethod
40
+ def is_available() -> bool:
41
+ """Check if Playwright is installed and usable."""
42
+ try:
43
+ from playwright.async_api import async_playwright
44
+ return True
45
+ except ImportError:
46
+ return False
47
+
48
+ async def _ensure_browser(self):
49
+ """Start the persistent browser if it's not running."""
50
+ global _playwright, _browser
51
+
52
+ async with _lock:
53
+ if _browser and _browser.is_connected():
54
+ return
55
+
56
+ logger.info("πŸš€ Copilot: Launching Persistent Browser...")
57
+ from playwright.async_api import async_playwright
58
+
59
+ _playwright = await async_playwright().start()
60
+ _browser = await _playwright.chromium.launch(
61
+ headless=True,
62
+ args=[
63
+ "--disable-blink-features=AutomationControlled",
64
+ "--no-sandbox",
65
+ "--disable-dev-shm-usage",
66
+ "--disable-gpu",
67
+ "--disable-web-security",
68
+ "--disable-features=IsolateOrigins,site-per-process",
69
+ ],
70
+ )
71
+ logger.info("βœ… Copilot: Browser is Ready.")
72
+
73
+ async def send_message(
74
+ self,
75
+ prompt: str,
76
+ model: str | None = None,
77
+ system_prompt: str | None = None,
78
+ **kwargs,
79
+ ) -> dict:
80
+ """Send a message via Copilot browser automation."""
81
+ if not self.is_available():
82
+ raise RuntimeError("Playwright not installed.")
83
+
84
+ await self._ensure_browser()
85
+ selected_model = model or "copilot-gpt-4"
86
+
87
+ # Create Ephemeral Context
88
+ context = await _browser.new_context(
89
+ viewport={"width": 1920, "height": 1080},
90
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
91
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
92
+ "Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
93
+ locale="en-US",
94
+ timezone_id="America/New_York",
95
+ )
96
+
97
+ # Hide webdriver flag
98
+ await context.add_init_script("""
99
+ Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
100
+ Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
101
+ window.chrome = { runtime: {} };
102
+ """)
103
+
104
+ page = await context.new_page()
105
+
106
+ try:
107
+ logger.info(f"Copilot request: {selected_model}")
108
+
109
+ # Navigate to Copilot
110
+ await page.goto("https://copilot.microsoft.com/", timeout=60000)
111
+
112
+ # Wait for the chat input to be ready
113
+ # Copilot uses contenteditable divs
114
+ input_selectors = [
115
+ '[data-testid="chat-input"]',
116
+ 'div[contenteditable="true"]',
117
+ '[role="textbox"]',
118
+ 'textarea',
119
+ '.input-area div[contenteditable]',
120
+ ]
121
+
122
+ input_selector = None
123
+ for sel in input_selectors:
124
+ try:
125
+ await page.wait_for_selector(sel, timeout=10000)
126
+ input_selector = sel
127
+ logger.info(f"βœ… Copilot: Found input selector: {sel}")
128
+ break
129
+ except:
130
+ continue
131
+
132
+ if not input_selector:
133
+ raise RuntimeError("Could not find Copilot chat input")
134
+
135
+ await asyncio.sleep(self.HYDRATION_DELAY)
136
+
137
+ # Type the message
138
+ full_prompt = prompt
139
+ if system_prompt:
140
+ full_prompt = f"[System: {system_prompt}]\n\n{prompt}"
141
+
142
+ await page.click(input_selector)
143
+ await page.keyboard.type(full_prompt, delay=10)
144
+ await asyncio.sleep(0.5)
145
+ await page.keyboard.press("Enter")
146
+
147
+ logger.info("Copilot: Message sent...")
148
+
149
+ # Wait for response
150
+ response_text = await self._wait_for_response(page)
151
+
152
+ if not response_text:
153
+ raise ValueError("Empty response from Copilot")
154
+
155
+ return {
156
+ "response": response_text,
157
+ "model": selected_model,
158
+ }
159
+
160
+ except Exception as e:
161
+ logger.error(f"Copilot Error: {e}")
162
+ raise
163
+ finally:
164
+ await context.close()
165
+
166
+ async def _wait_for_response(self, page) -> str:
167
+ """Wait for and extract the AI response from the DOM."""
168
+ last_text = ""
169
+ stable_count = 0
170
+ required_stable = 4
171
+
172
+ for i in range(self.RESPONSE_TIMEOUT * 2):
173
+ await asyncio.sleep(0.5)
174
+
175
+ # Check for "Continue" button and click it
176
+ try:
177
+ continue_btn = await page.query_selector(
178
+ 'button:has-text("Continue"), button:has-text("Continue anyway")'
179
+ )
180
+ if continue_btn and await continue_btn.is_visible():
181
+ logger.info("Copilot: Clicking 'Continue' button...")
182
+ await continue_btn.click()
183
+ await asyncio.sleep(1)
184
+ except:
185
+ pass
186
+
187
+ # Extract response text
188
+ current_text = await page.evaluate("""
189
+ () => {
190
+ const selectors = [
191
+ '[data-testid="assistant-message"]',
192
+ '.message-content',
193
+ '[data-message-author-role="assistant"]',
194
+ '.ac-textBlock',
195
+ '[class*="response"]',
196
+ '[class*="message"] div',
197
+ '.markdown-body',
198
+ ];
199
+
200
+ for (const sel of selectors) {
201
+ const els = document.querySelectorAll(sel);
202
+ if (els.length > 0) {
203
+ const last = els[els.length - 1];
204
+ const text = last.innerText || last.textContent || '';
205
+ if (text.trim().length > 10) return text.trim();
206
+ }
207
+ }
208
+ return '';
209
+ }
210
+ """)
211
+
212
+ if not current_text:
213
+ continue
214
+
215
+ # Clean the text
216
+ clean = self._clean_response(current_text)
217
+
218
+ if clean == last_text and len(clean) > 0:
219
+ stable_count += 1
220
+ if stable_count >= required_stable:
221
+ return clean
222
+ else:
223
+ stable_count = 0
224
+ last_text = clean
225
+
226
+ if i % 10 == 9:
227
+ logger.info(f"Copilot: Stream... {len(last_text)} chars")
228
+
229
+ if last_text:
230
+ logger.warning("Copilot: Timeout, returning partial.")
231
+ return last_text
232
+
233
+ raise TimeoutError("Copilot no response")
234
+
235
+ def _clean_response(self, text: str) -> str:
236
+ """Clean up Copilot response text."""
237
+ clean = text.strip()
238
+
239
+ # Remove common UI artifacts
240
+ clean = re.sub(r"^(Copilot\s*|Microsoft Copilot\s*)", "", clean, flags=re.IGNORECASE)
241
+ clean = re.sub(r"\n+\s*\n+", "\n\n", clean)
242
+
243
+ return clean.strip()
test_copilot_browser.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script for Microsoft Copilot Provider
3
+ Run this to verify the Copilot browser automation works.
4
+ """
5
+
6
+ import asyncio
7
+ import sys
8
+ import os
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from providers.copilot_provider import CopilotProvider
13
+
14
+
15
+ async def test_copilot():
16
+ """Test the Copilot provider."""
17
+ print("πŸ§ͺ Testing Microsoft Copilot Provider...")
18
+ print("-" * 50)
19
+
20
+ provider = CopilotProvider()
21
+
22
+ # Check if Playwright is available
23
+ if not provider.is_available():
24
+ print("❌ Playwright not installed. Run: pip install playwright && playwright install chromium")
25
+ return False
26
+
27
+ print("βœ… Playwright is available")
28
+ print(f"πŸ“‹ Available models: {provider.get_available_models()}")
29
+ print()
30
+
31
+ # Test prompts
32
+ test_prompts = [
33
+ "Say 'Hello from Copilot test' and nothing else.",
34
+ "What is 2+2? Answer with just the number.",
35
+ ]
36
+
37
+ for i, prompt in enumerate(test_prompts, 1):
38
+ print(f"\nπŸ“ Test {i}: {prompt[:50]}...")
39
+ print("-" * 50)
40
+
41
+ try:
42
+ result = await provider.send_message(prompt)
43
+ print(f"βœ… SUCCESS!")
44
+ print(f"πŸ€– Model: {result['model']}")
45
+ print(f"πŸ’¬ Response: {result['response'][:200]}...")
46
+ print()
47
+ except Exception as e:
48
+ print(f"❌ FAILED: {e}")
49
+ import traceback
50
+ traceback.print_exc()
51
+ return False
52
+
53
+ print("\n" + "=" * 50)
54
+ print("πŸŽ‰ All Copilot tests passed!")
55
+ print("=" * 50)
56
+ return True
57
+
58
+
59
+ if __name__ == "__main__":
60
+ success = asyncio.run(test_copilot())
61
+ sys.exit(0 if success else 1)