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.
- 🚫 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. |
@@ -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": {