From 277a54d4c61c860f3b43b712f73d3f5f30a74578 Mon Sep 17 00:00:00 2001 From: skyzea1 <161649495+skyzea1@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:58:06 +0800 Subject: [PATCH 1/2] feat(providers): add Microsoft 365 Copilot individual provider --- AGENTS.md | 4 +- CLAUDE.md | 2 +- README.md | 12 +- docs/architecture/ARCHITECTURE.md | 4 +- docs/architecture/CODEBASE_DOCUMENTATION.md | 4 +- docs/reference/PROVIDER_REFERENCE.md | 9 +- open-sse/config/providers/index.ts | 2 + .../registry/copilot-m365-web/index.ts | 12 + open-sse/executors/copilot-m365-connection.ts | 55 +++- open-sse/executors/copilot-m365-frames.ts | 31 +- open-sse/executors/copilot-m365-web.ts | 308 ++++++++++++++++++ open-sse/executors/index.ts | 3 + src/shared/constants/providers/web-cookie.ts | 13 + src/shared/providers/webSessionCredentials.ts | 7 + tests/unit/copilot-m365-web-executor.test.ts | 158 +++++++++ tests/unit/m365-bizchat-frames-4042.test.ts | 4 +- tests/unit/m365-connection-4042.test.ts | 1 + 17 files changed, 609 insertions(+), 20 deletions(-) create mode 100644 open-sse/config/providers/registry/copilot-m365-web/index.ts create mode 100644 open-sse/executors/copilot-m365-web.ts create mode 100644 tests/unit/copilot-m365-web-executor.test.ts diff --git a/AGENTS.md b/AGENTS.md index 289ed002962..ce665709a4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,12 @@ ## Project Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support -with **236 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, +with **237 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra, SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more) with **MCP Server** (94 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. -> **Live counts (v3.8.40)**: providers 236 · MCP tools 94 · MCP scopes 30 · A2A skills 6 · +> **Live counts (v3.8.40)**: providers 237 · MCP tools 94 · MCP scopes 30 · A2A skills 6 · > open-sse services 298 · routing strategies 17 · auto-combo scoring factors 12 · > DB modules 94 · DB migrations 106 · base tables 17 · search providers 11 · > i18n locales 42. **Refresh with `npm run check:docs-all`.** diff --git a/CLAUDE.md b/CLAUDE.md index 3bbbb73f418..d21aa5a08be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 236 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 237 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/README.md b/README.md index 4bca7a134cd..ae9c08b8aa3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # 🚀 OmniRoute — The Free AI Gateway -### Never stop coding. Connect every AI tool to **236 providers** — **50+ free** — through one endpoint. +### Never stop coding. Connect every AI tool to **237 providers** — **50+ free** — through one endpoint. **Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
@@ -138,11 +138,11 @@ -> One endpoint. **236 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. +> One endpoint. **237 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. - + @@ -301,7 +301,7 @@ Result: 4 layers of fallback = zero downtime - **💸 Cost telemetry everywhere** — `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md) - **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md) - **🛡️ Security** — a prompt-injection guard across every LLM route (backed by a red-team suite), plus a free DuckDuckGo last-resort web search. → [Guardrails](docs/security/GUARDRAILS.md) -- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 236-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md) +- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 237-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **⚡ Local performance & infra** — a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel), one-click **Cloudflare Workers** and **Deno Deploy** relay deployers wired into the proxy pool, and an optional Bifrost Go sidecar that offloads the hottest relay path (`BIFROST_BASE_URL`, with automatic fallback to the TypeScript path on timeout). → [Environment](docs/reference/ENVIRONMENT.md)
@@ -348,7 +348,7 @@ Result: 4 layers of fallback = zero downtime -> The most complete catalog of any open-source router: **236 providers**, **50+ with a free tier**, **11 free forever**. +> The most complete catalog of any open-source router: **237 providers**, **50+ with a free tier**, **11 free forever**.
@@ -804,7 +804,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo **Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system. **Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0. **Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected. -**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 236 providers. +**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 237 providers. 📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 12653e091c0..e91387e5352 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr Core capabilities: -- OpenAI-compatible API surface for CLI/tools (236 providers, 67 executors) +- OpenAI-compatible API surface for CLI/tools (237 providers, 68 executors) - Request/response translation across provider formats - Model combo fallback (multi-model sequence) - Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers` @@ -933,7 +933,7 @@ All other providers (including custom compatible nodes) use the `DefaultExecutor ## Provider Compatibility Matrix -> **Note:** The matrix below is a representative sample of the 236 registered providers in +> **Note:** The matrix below is a representative sample of the 237 registered providers in > OmniRoute v3.8.0. For the canonical and continuously-updated list, refer to > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md) (auto-generated) or the source of > truth at `src/shared/constants/providers.ts` (Zod-validated at load). diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index a4e8d9eea90..599c5dc58b5 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -482,7 +482,7 @@ open-sse/ ### 4.2 `open-sse/executors/` -67 provider executors, each extending `BaseExecutor` (`base.ts`): +68 provider executors, each extending `BaseExecutor` (`base.ts`): `antigravity`, `azure-openai`, `blackbox-web`, `chatgpt-web`, `cliproxyapi`, `cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`, @@ -491,7 +491,7 @@ open-sse/ (shared identity helper) and `index.ts` (registry). > Note: providers not listed here are served by `default.ts` using the generic -> OpenAI-compatible executor. The full provider catalog (236 entries) lives in +> OpenAI-compatible executor. The full provider catalog (237 entries) lives in > `src/shared/constants/providers.ts`. ### 4.3 `open-sse/translator/` diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 79da503f788..a7e3dc0f66e 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.40 -lastUpdated: 2026-06-28 +lastUpdated: 2026-06-29 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-06-28 +> **Last generated:** 2026-06-29 -Total providers: **236**. See category breakdown below. +Total providers: **237**. See category breakdown below. ## Categories @@ -56,7 +56,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | | `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | -## Web Cookie Providers (23) +## Web Cookie Providers (24) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -64,6 +64,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | | `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | | `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | +| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Paste the access_token and account-specific Chathub path from the Microsoft 365 Copilot WebSocket URL. | | `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | | `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | | `doubao-web` | `db` | Doubao Web (ByteDance) | Web cookie | [link](https://www.doubao.com) | Paste your session cookie from doubao.com (DevTools → Application → Cookies) | diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 80abaeab02c..062572fb683 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -103,6 +103,7 @@ import { openrouterProvider } from "./registry/openrouter/index.ts"; import { orcarouterProvider } from "./registry/orcarouter/index.ts"; import { glhfProvider } from "./registry/glhf/index.ts"; import { copilot_webProvider } from "./registry/copilot-web/index.ts"; +import { copilot_m365_webProvider } from "./registry/copilot-m365-web/index.ts"; import { stepfunProvider } from "./registry/stepfun/index.ts"; import { freemodel_devProvider } from "./registry/freemodel-dev/index.ts"; import { gitlawb_gmiProvider } from "./registry/gitlawb/gmi/index.ts"; @@ -274,6 +275,7 @@ export const REGISTRY: Record = { orcarouter: orcarouterProvider, glhf: glhfProvider, "copilot-web": copilot_webProvider, + "copilot-m365-web": copilot_m365_webProvider, stepfun: stepfunProvider, "freemodel-dev": freemodel_devProvider, "gitlawb-gmi": gitlawb_gmiProvider, diff --git a/open-sse/config/providers/registry/copilot-m365-web/index.ts b/open-sse/config/providers/registry/copilot-m365-web/index.ts new file mode 100644 index 00000000000..74291532226 --- /dev/null +++ b/open-sse/config/providers/registry/copilot-m365-web/index.ts @@ -0,0 +1,12 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const copilot_m365_webProvider: RegistryEntry = { + id: "copilot-m365-web", + alias: "m365copilot", + format: "openai", + executor: "copilot-m365-web", + baseUrl: "wss://substrate.office.com/m365Copilot/Chathub", + authType: "apikey", + authHeader: "cookie", + models: [{ id: "copilot-m365", name: "Microsoft 365 Copilot (BizChat)" }], +}; diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts index b3c2a1507d4..5c648f4b99f 100644 --- a/open-sse/executors/copilot-m365-connection.ts +++ b/open-sse/executors/copilot-m365-connection.ts @@ -24,10 +24,61 @@ export const M365_INDIVIDUAL_DEFAULTS = { scenario: "OfficeWebPaidConsumerCopilot", } as const; +export const M365_DEFAULT_VARIANTS = [ + "EnableMcpServerWidgets", + "feature.EnableMcpServerWidgets", + "feature.EnableLuForChatCIQ", + "feature.enableChatCIQPlugin", + "EnableRequestPlugins", + "feature.EnableSensitivityLabels", + "EnableUnsupportedUrlDetector", + "feature.IsCustomEngineCopilotEnabled", + "feature.bizchatfluxv3", + "feature.enablechatpages", + "feature.enableCodeCanvas", + "feature.turnOnDARecommendation", + "feature.IsStreamingModeInChatRequestEnabled", + "IncludeSourceAttributionsConcise", + "SkipPublishEmptyMessage", + "feature.EnableDeduplicatingSourceAttributions", + "Enable3PActionProgressMessages", + "feature.enableClientWebRtc", + "feature.EnableMeetingRecapOfSeriesMeetingWithCiq", + "feature.cwcfluxv3fe", + "feature.cwcfluxv3fem", + "feature.EnableReferencesListCompleteSignal", + "feature.StorageMessageSplitDisabled", + "feature.EnableCuaTakeControlApi", + "SingletonEnvOn", + "EnableComposeWidget", + "feature.cwcallowedos", + "feature.EnableMergingPureDeltas", + "feature.disabledisallowedmsgs", + "feature.enableCitationsForSynthesisData", + "feature.EnableConversationShareApis", + "feature.enableGenerateGraphicArtOptionsSet", + "cdximagen", + "feature.EnableUpdatedUXForConfirmationDialog", + "feature.EnableContentApiandDocTypeHtmlInRichAnswers", + "cdxgrounding_api_v2_rich_web_answers_reference_bottom_force", + "cdxenablerenderforisocomp", + "feature.EnableClientFileURLSupportForOfficeWebPaidCopilot", + "feature.EnableDesignEditorImageGrounding", + "feature.EnableDesignerEditor", + "feature.EnableSkipRehydrationForSpeCIdImages", + "feature.EnablePersonalizationForMSA", + "agt_bizchat_enableRichResponses", + "feature.EnableBase64DataInMessageAnnotations", + "feature.EnableSkipEmittingMessageOnFlush", + "feature.EnableRemoveEmptySourceAttributions", + "feature.EnableRemoveStreamingMode", +] as const; + export interface M365ConnectionParams { host: string; chathubPath: string; // "@" accessToken: string; + variants?: string; } /** A new 32-hex chat session id (== XRoutingParameterSessionKey == clientrequestid). */ @@ -63,7 +114,8 @@ export function resolveConnectionParams( }; } const host = (typeof psd.host === "string" && psd.host) || M365_INDIVIDUAL_DEFAULTS.host; - return { host, chathubPath, accessToken }; + const variants = typeof psd.variants === "string" && psd.variants ? psd.variants : undefined; + return { host, chathubPath, accessToken, variants }; } /** @@ -80,6 +132,7 @@ export function buildWsUrl(params: M365ConnectionParams): string { "X-SessionId": randomUUID(), ConversationId: randomUUID(), access_token: params.accessToken, + variants: params.variants ?? M365_DEFAULT_VARIANTS.join(","), source: M365_INDIVIDUAL_DEFAULTS.source, product: M365_INDIVIDUAL_DEFAULTS.product, agentHost: M365_INDIVIDUAL_DEFAULTS.agentHost, diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts index e236792dc24..267b897c512 100644 --- a/open-sse/executors/copilot-m365-frames.ts +++ b/open-sse/executors/copilot-m365-frames.ts @@ -40,6 +40,34 @@ export const ALLOWED_MESSAGE_TYPES = [ "GenerateContentQuery", ] as const; +export const M365_DEFAULT_OPTION_SETS = [ + "search_result_progress_messages_with_search_queries", + "update_textdoc_response_after_streaming", + "deepleo_networking_timeout_10minutes_canmore", + "cwc_flux_image", + "cwc_code_interpreter", + "cwc_code_interpreter_amsfix", + "enable_msa_user", + "cwcgptv", + "flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch", + "gptvnorm2048", + "pdnascan", + "cwc_code_interpreter_citation_fix", + "code_interpreter_interactive_charts", + "cwc_code_interpreter_interactive_charts_inline_image", + "code_interpreter_matplotlib_patching", + "cwc_fileupload_odb", + "update_memory_plugin", + "add_custom_instructions", + "cwc_flux_v3", + "flux_v3_progress_messages", + "enable_batch_token_processing", + "enable_gg_gpt", + "flux_v3_image_gen_enable_non_watermarked_storage", + "flux_v3_image_gen_enable_story", + "rich_responses", +] as const; + /** Append the record separator to a JSON-serializable frame. */ export function encodeFrame(obj: unknown): string { return JSON.stringify(obj) + RECORD_SEPARATOR; @@ -118,7 +146,7 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record | null): string | if (!m) continue; const author = m.author; const text = m.text; + if (m.messageType === "Progress" || m.contentType === "EarlyProgress") continue; if ((author === "bot" || author === undefined) && typeof text === "string" && text.length > 0) { return text; } diff --git a/open-sse/executors/copilot-m365-web.ts b/open-sse/executors/copilot-m365-web.ts new file mode 100644 index 00000000000..7b7470c4a27 --- /dev/null +++ b/open-sse/executors/copilot-m365-web.ts @@ -0,0 +1,308 @@ +import WebSocket from "ws"; +import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { + buildPrompt, + buildWsUrl, + redactWsUrl, + resolveConnectionParams, +} from "./copilot-m365-connection.ts"; +import { + buildChatInvocation, + encodeFrame, + extractBotText, + handshakeError, + handshakeFrame, + incrementalDelta, + isCompletionFrame, + keepaliveFrame, + parseFrame, + splitFrames, +} from "./copilot-m365-frames.ts"; + +type JsonRecord = Record; +let WebSocketCtor: typeof WebSocket = WebSocket; + +export function __setCopilotM365WebSocketForTesting(ctor: typeof WebSocket): () => void { + const previous = WebSocketCtor; + WebSocketCtor = ctor; + return () => { + WebSocketCtor = previous; + }; +} + +function sseChunk(model: string, delta: JsonRecord, finishReason: string | null = null): string { + return `data: ${JSON.stringify({ + id: `chatcmpl-copilot-m365-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + })}\n\n`; +} + +function errorResponse(message: string, status = 502): Response { + return new Response(JSON.stringify({ error: { message } }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export class CopilotM365WebExecutor extends BaseExecutor { + constructor() { + super("copilot-m365-web", { id: "copilot-m365-web", baseUrl: "wss://substrate.office.com" }); + } + + private async wsChat(input: { + wsUrl: string; + prompt: string; + model: string; + signal?: AbortSignal; + }): Promise> { + return new ReadableStream( + { + start: async (controller) => { + const encoder = new TextEncoder(); + let ws: WebSocket | null = null; + let settled = false; + let buffer = ""; + let previousText = ""; + let handshakeComplete = false; + + const cleanup = () => { + if (ws) { + try { + ws.close(); + } catch { + /* ignore */ + } + ws = null; + } + }; + + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + controller.enqueue(encoder.encode(sseChunk(input.model, {}, "stop"))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }; + + const abort = (reason: string) => { + if (settled) return; + settled = true; + cleanup(); + const message = sanitizeErrorMessage(reason); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: { message } })}\n\n`)); + controller.close(); + }; + + input.signal?.addEventListener("abort", () => abort("Request aborted"), { once: true }); + + const timeout = setTimeout( + () => abort("Microsoft 365 Copilot WebSocket timeout"), + FETCH_TIMEOUT_MS + ); + + try { + const wsUrlParts = new URL(input.wsUrl); + const traceId = wsUrlParts.searchParams.get("clientrequestid") ?? crypto.randomUUID().replace(/-/g, ""); + const sessionId = wsUrlParts.searchParams.get("X-SessionId") ?? crypto.randomUUID(); + + ws = new WebSocketCtor(input.wsUrl, { + headers: { + Origin: "https://m365.cloud.microsoft", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + }, + }); + + const sendChat = () => { + ws?.send(keepaliveFrame()); + ws?.send( + encodeFrame( + buildChatInvocation({ + text: input.prompt, + traceId, + sessionId, + isStartOfSession: true, + }) + ) + ); + }; + + ws.on("open", () => { + ws?.send(handshakeFrame()); + }); + + ws.on("message", (data) => { + if (settled) return; + buffer += data.toString(); + const split = splitFrames(buffer); + buffer = split.rest; + + for (const rawFrame of split.frames) { + const frame = parseFrame(rawFrame); + if (!handshakeComplete) { + const err = handshakeError(frame); + if (err) { + clearTimeout(timeout); + abort(`Microsoft 365 Copilot handshake failed: ${err}`); + return; + } + handshakeComplete = true; + sendChat(); + continue; + } + + const text = extractBotText(frame); + if (text) { + const delta = incrementalDelta(previousText, text); + previousText = text; + if (delta) { + controller.enqueue(encoder.encode(sseChunk(input.model, { content: delta }))); + } + } + + if (isCompletionFrame(frame)) { + clearTimeout(timeout); + finish(); + return; + } + } + }); + + ws.on("error", (err) => { + clearTimeout(timeout); + abort( + sanitizeErrorMessage( + err instanceof Error ? err.message : "Microsoft 365 Copilot WebSocket error" + ) + ); + }); + + ws.on("close", () => { + clearTimeout(timeout); + finish(); + }); + } catch (err) { + clearTimeout(timeout); + abort( + sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to connect to Microsoft 365 Copilot" + ) + ); + } + }, + }, + { highWaterMark: 16384 } + ); + } + + async execute(input: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const body = input.body as JsonRecord | undefined; + const model = input.model || (body?.model as string) || "copilot-m365"; + const stream = input.stream !== false; + const prompt = buildPrompt(body).trim(); + + if (!prompt) { + return { + response: errorResponse("No user message provided", 400), + url: "wss://substrate.office.com/m365Copilot/Chathub", + headers: {}, + transformedBody: null, + }; + } + + const connectionParams = resolveConnectionParams(input.credentials); + if ("error" in connectionParams) { + return { + response: errorResponse(connectionParams.error, 400), + url: "wss://substrate.office.com/m365Copilot/Chathub", + headers: {}, + transformedBody: { model, prompt: prompt.slice(0, 100) }, + }; + } + + const wsUrl = buildWsUrl(connectionParams); + + try { + const wsStream = await this.wsChat({ wsUrl, prompt, model, signal: input.signal ?? undefined }); + + if (stream) { + return { + response: new Response(wsStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: redactWsUrl(wsUrl), + headers: {}, + transformedBody: { model, prompt: prompt.slice(0, 100) }, + }; + } + + const reader = wsStream.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + for (const line of decoder.decode(value, { stream: true }).split("\n")) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const parsed = JSON.parse(data); + const content = parsed.choices?.[0]?.delta?.content; + if (typeof content === "string") fullText += content; + } catch { + /* skip malformed SSE lines */ + } + } + } + + return { + response: new Response( + JSON.stringify({ + id: `chatcmpl-copilot-m365-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: fullText || "(empty response)" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + { headers: { "Content-Type": "application/json" } } + ), + url: redactWsUrl(wsUrl), + headers: {}, + transformedBody: { model, prompt: prompt.slice(0, 100) }, + }; + } catch (err) { + const message = sanitizeErrorMessage( + err instanceof Error ? err.message : "Microsoft 365 Copilot executor error" + ); + return { + response: errorResponse(message), + url: redactWsUrl(wsUrl), + headers: {}, + transformedBody: { model, prompt: prompt.slice(0, 100) }, + }; + } + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 456b3682406..7da5c49b9b8 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -33,6 +33,7 @@ import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-ref import { AdaptaWebExecutor } from "./adapta-web.ts"; import { ClaudeWebWithAutoRefresh } from "./claude-web-with-auto-refresh.ts"; import { CopilotWebExecutor } from "./copilot-web.ts"; +import { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; import { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; import { T3ChatWebExecutor } from "./t3-chat-web.ts"; @@ -115,6 +116,7 @@ const executors = { "adapta-web": new AdaptaWebExecutor(), "adp-web": new AdaptaWebExecutor(), // Alias "copilot-web": new CopilotWebExecutor(), + "copilot-m365-web": new CopilotM365WebExecutor(), copilot: new CopilotWebExecutor(), // Alias "veoaifree-web": new VeoAIFreeWebExecutor(), "veo-free": new VeoAIFreeWebExecutor(), // Alias @@ -201,6 +203,7 @@ export { NlpCloudExecutor } from "./nlpcloud.ts"; export { WindsurfExecutor } from "./windsurf.ts"; export { DevinCliExecutor } from "./devin-cli.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; +export { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; export { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; export { ClaudeWebExecutor } from "./claude-web.ts"; diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 30ac72b7794..97e0d52527b 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -116,6 +116,19 @@ export const WEB_COOKIE_PROVIDERS = { subscriptionRisk: true, riskNoticeVariant: "webCookie", }, + "copilot-m365-web": { + id: "copilot-m365-web", + alias: "m365copilot", + name: "Microsoft 365 Copilot (BizChat)", + icon: "business_center", + color: "#0078D4", + textIcon: "M365", + website: "https://m365.cloud.microsoft/chat", + authHint: + "Paste the access_token and account-specific Chathub path from the Microsoft 365 Copilot WebSocket URL.", + subscriptionRisk: true, + riskNoticeVariant: "webCookie", + }, "t3-web": { id: "t3-web", alias: "t3chat", diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 6772fb2a0c3..84990e6ac25 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -94,6 +94,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: false, storageKeys: ["token", "access_token", "accessToken"], }, + "copilot-m365-web": { + kind: "token", + credentialName: "access_token + chathubPath", + placeholder: "access_token=...; chathubPath=redacted", + acceptsFullCookieHeader: false, + storageKeys: ["token", "access_token", "accessToken", "chathubPath", "userTenant"], + }, "t3-web": { kind: "cookie", credentialName: "convex-session-id + Cookie header", diff --git a/tests/unit/copilot-m365-web-executor.test.ts b/tests/unit/copilot-m365-web-executor.test.ts new file mode 100644 index 00000000000..3150082cb11 --- /dev/null +++ b/tests/unit/copilot-m365-web-executor.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CopilotM365WebExecutor, + __setCopilotM365WebSocketForTesting, +} from "../../open-sse/executors/copilot-m365-web.ts"; +import { encodeFrame } from "../../open-sse/executors/copilot-m365-frames.ts"; + +type Listener = (...args: unknown[]) => void; + +class MockM365WebSocket { + static instances: MockM365WebSocket[] = []; + static mode: "success" | "error" = "success"; + + sent: string[] = []; + closed = false; + listeners = new Map(); + + constructor( + public url: string, + public options: unknown + ) { + MockM365WebSocket.instances.push(this); + queueMicrotask(() => { + if (MockM365WebSocket.mode === "error") { + this.emit("error", new Error("upstream transport failed\nstack line")); + return; + } + this.emit("open"); + }); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + send(data: string): void { + this.sent.push(String(data)); + const parsed = JSON.parse(String(data).replace(/\x1e$/, "")); + if (parsed.protocol === "json") { + queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({})))); + return; + } + if (parsed.type === 4 && parsed.target === "chat") { + queueMicrotask(() => { + this.emit( + "message", + Buffer.from( + encodeFrame({ + type: 1, + target: "update", + arguments: [{ messages: [{ text: "In progress...", messageType: "Progress", author: "bot" }] }], + }) + + encodeFrame({ + type: 1, + target: "update", + arguments: [{ messages: [{ text: "po", author: "bot" }] }], + }) + + encodeFrame({ + type: 1, + target: "update", + arguments: [{ messages: [{ text: "pong", author: "bot" }], isLastUpdate: true }], + }) + + encodeFrame({ type: 2, invocationId: "0", item: { messages: [] } }) + + encodeFrame({ type: 3, invocationId: "0" }) + ) + ); + }); + } + } + + close(): void { + this.closed = true; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } +} + +function makeInput(stream = true) { + return { + model: "copilot-m365", + stream, + body: { messages: [{ role: "user", content: "Reply with exactly one word: pong" }] }, + credentials: { + apiKey: "redacted-token", + providerSpecificData: { chathubPath: "redacted-user@redacted-tenant" }, + }, + }; +} + +async function readBody(response: Response): Promise { + return await response.text(); +} + +test("CopilotM365WebExecutor streams OpenAI SSE chunks from accumulated M365 updates", async () => { + MockM365WebSocket.instances = []; + MockM365WebSocket.mode = "success"; + const restore = __setCopilotM365WebSocketForTesting( + MockM365WebSocket as unknown as typeof import("ws").default + ); + try { + const executor = new CopilotM365WebExecutor(); + const result = await executor.execute(makeInput(true)); + const body = await readBody(result.response); + + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + assert.match(result.url, /access_token=REDACTED/); + assert.doesNotMatch(result.url, /redacted-token/); + assert.equal(MockM365WebSocket.instances.length, 1); + + const sent = MockM365WebSocket.instances[0].sent.join("\n"); + assert.match(sent, /"protocol":"json"/); + assert.match(sent, /"type":6/); + assert.match(sent, /"target":"chat"/); + + const dataLines = body + .split("\n") + .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]"); + const payloads = dataLines.map((line) => JSON.parse(line.slice("data: ".length))); + const deltas = payloads.map((payload) => payload.choices?.[0]?.delta?.content).filter(Boolean); + const finishReasons = payloads.map((payload) => payload.choices?.[0]?.finish_reason).filter(Boolean); + + assert.deepEqual(deltas, ["po", "ng"]); + assert.deepEqual(finishReasons, ["stop"]); + assert.match(body, /data: \[DONE\]/); + assert.doesNotMatch(body, /In progress/); + } finally { + restore(); + } +}); + +test("CopilotM365WebExecutor sanitizes WebSocket error SSE payloads", async () => { + MockM365WebSocket.instances = []; + MockM365WebSocket.mode = "error"; + const restore = __setCopilotM365WebSocketForTesting( + MockM365WebSocket as unknown as typeof import("ws").default + ); + try { + const executor = new CopilotM365WebExecutor(); + const result = await executor.execute(makeInput(true)); + const body = await readBody(result.response); + + assert.match(body, /data: /); + assert.match(body, /upstream transport failed/); + assert.doesNotMatch(body, /stack line/); + assert.doesNotMatch(body, /\nstack/); + } finally { + restore(); + } +}); diff --git a/tests/unit/m365-bizchat-frames-4042.test.ts b/tests/unit/m365-bizchat-frames-4042.test.ts index b67323148f6..b1179d098a4 100644 --- a/tests/unit/m365-bizchat-frames-4042.test.ts +++ b/tests/unit/m365-bizchat-frames-4042.test.ts @@ -29,7 +29,7 @@ const HANDSHAKE_ACK = {}; // SignalR success ack const PROGRESS_UPDATE = { type: 1, target: "update", - arguments: [{ messages: [{ messageType: "Progress", author: "bot" }] }], + arguments: [{ messages: [{ text: "In progress…", messageType: "Progress", author: "bot" }] }], }; const BOT_UPDATE = { type: 1, @@ -128,6 +128,8 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te assert.equal(arg.clientCorrelationId, "trace-id"); assert.equal(arg.sessionId, "session-id"); assert.equal(arg.isStartOfSession, true); + assert.ok(Array.isArray(arg.optionsSets)); + assert.ok((arg.optionsSets as string[]).includes("rich_responses")); assert.ok(Array.isArray(arg.allowedMessageTypes)); assert.ok((arg.allowedMessageTypes as string[]).includes("Chat")); const message = arg.message as Record; diff --git a/tests/unit/m365-connection-4042.test.ts b/tests/unit/m365-connection-4042.test.ts index af4890de625..3be24a9fdc8 100644 --- a/tests/unit/m365-connection-4042.test.ts +++ b/tests/unit/m365-connection-4042.test.ts @@ -65,6 +65,7 @@ test("buildWsUrl targets the substrate Chathub with the individual-tier query", assert.equal(qs.get("agent"), "web"); assert.equal(qs.get("scenario"), "OfficeWebPaidConsumerCopilot"); assert.equal(qs.get("source"), "officeweb"); + assert.ok(qs.get("variants")?.includes("feature.bizchatfluxv3")); assert.equal(qs.get("access_token"), "TOK"); // chatsessionid == XRoutingParameterSessionKey == clientrequestid (same value) const sid = qs.get("chatsessionid"); From 74f99e947cc4f86df54c400df2b04149e3e1046a Mon Sep 17 00:00:00 2001 From: skyzea1 Date: Mon, 29 Jun 2026 03:25:19 -0300 Subject: [PATCH 2/2] test(providers): refresh provider translate-path golden for copilot-m365-web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the Microsoft 365 Copilot web provider changes the provider→translate-path golden snapshot. Regenerated via UPDATE_GOLDEN=1; audited diff is purely additive (only the copilot-m365-web entry: openai format, wss substrate Chathub url, masked Bearer headers) — no other provider entry changed. Co-authored-by: diegosouzapw --- tests/snapshots/provider/translate-path.json | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 0ac8561625a..e1952f24d34 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -940,6 +940,29 @@ "stream": "https://api.commandcode.ai" } }, + "copilot-m365-web": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "wss://substrate.office.com/m365Copilot/Chathub", + "stream": "wss://substrate.office.com/m365Copilot/Chathub" + } + }, "copilot-web": { "format": "openai", "headers": {
🚫 Never hit limits
Auto-fallback across 236 providers in milliseconds. Quota out? Next provider takes over — zero downtime.
🚫 Never hit limits
Auto-fallback across 237 providers in milliseconds. Quota out? Next provider takes over — zero downtime.
💸 Save up to 95% tokens
RTK + Caveman stacked compression cuts 15–95% of eligible tokens (~89% avg on tool-heavy sessions).
🆓 $0 to start
50+ providers with a free tier, 11 free forever (Kiro, Qoder, Pollinations, LongCat…). No card needed.