跳到主要内容
    ↑↓ 选择↵ 打开esc 关闭
    chrischall

    M365 Copilot

    opencode-m365-copilot·v0.3.3·代码智能

    Use your org's Microsoft 365 Copilot as the model backend for opencode

    GitHub 星标

    2

    近 30 天 +1

    月装机量

    862

    近 7 天 255

    综合评分

    44.1

    生态多维模型

    最近提交

    2 天前

    2026-10-03

    快速安装与配置

    opencode.json

    写入当前项目的 opencode.json,只对这个仓库生效。

    opencode.json

    {
      "$schema": "https://opencode.ai/config.json",
      "plugin": ["opencode-m365-copilot@0.3.3"]
    }

    OpenCode 启动时会通过内嵌运行时自动加载 npm 依赖并缓存至本地目录,无需手动在全局环境执行安装。

    ci npm node licence: MIT

    Use your organisation's Microsoft 365 Copilot as the model backend for opencode.

    M365 Copilot has no developer API. It is an undocumented SignalR-over-WebSocket service (substrate.office.com) that Microsoft intends only its own clients to speak to. This package implements that protocol, wraps it in an OpenAI-compatible proxy, and ships an opencode plugin that starts the proxy in-process and registers it as a provider.

    opencode ──OpenAI /v1──▶ in-process proxy ──SignalR/WS──▶ M365 Copilot
                                  │
                                  └── local-title model, answered without any M365 call
    

    Install

    npm install -g opencode-m365-copilot
    opencode-m365 login      # one-time browser sign-in
    opencode-m365 setup      # adds the plugin to ~/.config/opencode/opencode.json
    opencode                 # models appear as m365/*
    

    setup writes only a plugin reference. The provider, the model list, the default model and the small model are all registered by the plugin at startup.

    Works on opencode 1 and opencode 2 from the one install — see opencode 2 for what differs.

    The thing you need to know first

    M365 refuses to work with a full coding-agent toolset. Its "Disengaged" filter returns an empty reply — indistinguishable from rate limiting unless you look for messageType: "Disengaged" — and the reference reverse-engineering notes measure the threshold at roughly 12 tools, naming opencode specifically as the harness that disengages persistently.

    So this plugin's main job is not wiring. It is cutting opencode down to something M365 will engage with. Measured against opencode 1.18.18 on a single agentic turn:

    stock with this plugin
    tools offered 9 4
    prompt sent to M365 53,676 chars 9,701 chars
    <available_skills> catalogue 34,263 chars removed

    The trimming is on by default. Turn it off with { "lean": false } and expect empty replies.

    Where the trimming happens, and why

    Not in opencode's config — in the proxy. Two opencode levers look like they should work and do not (verified against 1.18.18, and the reason the architecture is what it is):

    • config.tools is resolved into the config — opencode debug config shows it correctly — but the request still offers every tool. A config disabling eight tools produced a request offering all nine.
    • experimental.chat.system.transform fires, and accepts a replacement system array, but the request that reaches the provider still carries the original prompt. Setting agent.<name>.prompt from the config hook is ignored the same way.

    The proxy sees the final request, so it is the one place the trim reliably lands. The plugin still sets config.tools as a declaration of intent, in case a future version honours it.

    What gets dropped:

    • Tools — keeps bash, read, grep, and whichever editing tool is present (apply_patch in 1.18.18, edit/write in others). Drops glob, skill, task, todowrite, webfetch, websearch, lsp, question.
    • Capability catalogues — <available_skills> is removed when no skill tool survives the trim, because it advertises capabilities nothing can invoke. <env> and other structured context are kept.
    • The harness's prose system prompt — replaced with a short one, but off by default. A long, polished assistant prompt measurably reduces M365's tool compliance on another harness's numbers; that is a borrowed result we have not reproduced, so it is opt-in via { "leanSystemPrompt": true }. Your own rules are never dropped either way: opencode injects AGENTS.md and global rules as prose inside the system message, and those sections are preserved verbatim.

    bash is never dropped. M365's chat-tuned model will not "act as an agent" on request, but it will reflexively write a ```bash block — routing that block to the shell tool is the single lever that makes tool calling work at all.

    How tool calling works

    M365 has no native tool_calls. Tools go out as a fenced contract and come back as fenced blocks:

    ```read
    filePath: /etc/hostname
    ```
    

    Scalar arguments are key: value header lines, one free-form argument is the fence body, and an old/new pair is written as a SEARCH/REPLACE diff. A JSON {"tool": …} contract was tried in the reference project and scored 0/5 on real agentic tasks.

    The other half is a Copilot Studio declarative agent, provisioned automatically on the first turn that carries tools. Microsoft's server-side prompt outranks anything we send, so per-request instructions get answered in prose; instructions delivered through an agent land in the server-side system prompt and are obeyed. The agent is named after a hash of its instructions, so hosts on a tenant converge on one, and it is never deleted — another host may be mid-conversation with it.

    Without Copilot Studio access the plugin still works, just less reliably; it logs a warning and carries on.

    opencode 2

    opencode 2 is a separate package family (@opencode/cli, @opencode/plugin) with a different plugin API. This package serves both from a single default export: opencode 1 calls its server(), opencode 2 calls its setup(). There is nothing to choose and no second install.

    What differs, and why:

    opencode 1 opencode 2
    config key plugin plugins
    entry form [spec, options] { package, options }
    local reference the built entrypoint a directory — 2.0.11 drops a file path with configured plugin path must be a directory, and since it swallows plugin load failures, nothing else tells you
    provider registration config.provider.m365 ctx.provider.transform → editor.add
    openai-compatible driver installs @ai-sdk/openai-compatible bundled as @opencode/ai/providers/openai-compatible
    title generation small_model no such setting — see below

    opencode-m365 setup writes both keys, with the right reference for each. Each version silently drops the key it does not know, so the one file serves either.

    Everything in Where the trimming happens is unchanged, because it all lives in the proxy. opencode 2 does offer working equivalents for the two dead v1 hooks (ctx.tool.transform, session.hook("context")); neither has been measured against a live tenant, so the proxy stays the enforcement point.

    Verified how far

    The opencode 1 path is verified end to end against a real 1.18.31: the plugin loads, the provider registers with all 21 models, and the default and small models are set. The dual entry shape is also asserted against decompiled loader logic from 1.18.0, 1.18.18, 1.18.28 and 1.18.31 in src/plugin-entry.test.ts — opencode's docs say the object entry form arrived in 1.18.29, but 1.18.0 already has the identical detect path, so the peer floor did not move.

    The opencode 2 path is covered by unit tests against a stand-in context, and its catalog records are checked against the real @opencode/plugin schema constructors. It has not yet been exercised against a live opencode 2 end to end.

    Configuration

    {
      "plugin": [["opencode-m365-copilot", {
        "lean": true,              // trim the toolset (default: true)
        "leanSystemPrompt": false, // replace opencode's prose prompt (default: false)
        "setDefaultModel": true,   // set `model` if you have not (default: true)
        "setSmallModel": true,     // route title generation to the local titler (default: true)
        "baseUrl": null,           // use an already-running proxy instead of an in-process one
        "apiKey": null             // that proxy's secret (or export M365_PROXY_KEY instead)
      }]]
    }
    

    The in-process proxy needs no key configured: it mints a random secret each launch and hands it to opencode itself. Only a standalone opencode-m365 serve proxy needs one — serve prints its key, or takes a fixed one from M365_PROXY_KEY. Either put that key in apiKey, or export the same M365_PROXY_KEY to opencode and leave apiKey out.

    On opencode 2 the same block goes under plugins, as { "package": ..., "options": ... }.

    Why small_model is redirected

    opencode's small_model defaults to your main model, so every new session would generate its title on M365 — opening a second conversation. The account-level throttle counts conversations started, not messages, so that is the fastest way to get throttled. The m365/local-title model is answered by the proxy itself and never opens a connection.

    opencode 2 has no small_model, and its title hook exposes the model read-only, so the same guarantee is reached differently: the plugin marks the title request on a header (x-m365-request-kind: title) and the proxy answers it locally. Compaction and generation are left on the real model — they are real work, and only titling opens the extra conversation. setSmallModel controls both mechanisms.

    Models

    Models are selected by an M365 tone, not a model id. m365/gpt-5.5-think-deeper is the default.

    A conflict worth knowing about. The reference project's README recommends gpt-5.5-think-deeper and reports 100% tool compliance with the agent plus fenced/shell-routing. Its own protocol notes (m365-copilot-api.md, quirk #13) say the opposite — that reasoning tones meta-analyse the injected prompt and disengage, and only magic/*_Quick work with an agent attached. The README is the newer measurement, so it is the default here; if you see disengagement on tool turns, try m365/quick or m365/m365-copilot.

    A non-default tone (Claude, the reasoning tones) only takes effect when no agent is attached — with one, M365 silently routes to GPT regardless. The proxy therefore attaches the agent only on turns that carry tools, so plain chat reaches the model the tone selects.

    Authentication

    Sign-in is a CLI concern, never the plugin's: the plugin only refreshes silently and asks you to run opencode-m365 login if it cannot. A plugin running underneath a TUI has no business opening a browser.

    Two ways to sign in:

    • Automated (headless) — put { "email", "password", "mfaSecret" } in ~/.config/opencode-copilot/secrets.json. mfaSecret is the base32 seed your authenticator derives codes from (JBSWY3DPEHPK3PXP), not a 6-digit code. Most password managers will show it; an otpauth:// URI is accepted and the seed extracted. That file holds both sign-in factors — anyone who can read it can pass MFA as you — so keep it chmod 600 (the CLI tightens a looser file itself and warns), keep it off backups and synced folders, and prefer interactive sign-in if you can. It is only read by opencode-m365 login; you can delete it once you have signed in.
    • Interactive — opencode-m365 login --interactive opens a window and you complete SSO/MFA by hand once. Required for tenants with push-only MFA, FIDO2, or a federated IdP (Okta/Ping/Duo), where no seed exists to extract.

    Either way it is one-time; afterwards tokens refresh from the MSAL cache.

    The client id is Microsoft's own Office web Copilot application. That is not a shortcut — the Sydney scopes are granted to no other client, so a loopback redirect is rejected (AADSTS50011) and the device-code grant demands a client secret only Microsoft holds (AADSTS7000218). Driving a browser is the only door.

    State lives in ~/.config/opencode-copilot/ — deliberately not ~/.config/opencode-m365/, which belongs to the m365-copilot-proxy project.

    CLI

    opencode-m365 login [--interactive]   # sign in
    opencode-m365 setup [--local]         # register the plugin with opencode
    opencode-m365 serve [--port 4141]     # run the proxy standalone, for any OpenAI client
                                          # (send its key as `Authorization: Bearer`)
    opencode-m365 doctor                  # check auth, agent, proxy and opencode wiring
    

    Limits

    • 600 user messages per conversation. The proxy reuses one conversation per task, sends only new messages, and reports the remaining budget in usage.x_m365_conversation_remaining. A task is the opencode session on opencode 2, or the system prompt plus the first user message otherwise; turns within one task run one at a time.
    • Account-level throttling exists and is keyed to your identity, so re-authenticating does not clear it. It self-heals after a lull.
    • Streaming works for tool-less turns. A tool turn is buffered, because a fenced call cannot be parsed until the fence closes.
    • Tool calling is emulated, not native, and one call per turn is kept by default — M365 batches its whole plan into one response, and later steps run on guessed state.
    • usage reports zero tokens. M365 never exposes token counts; the x_m365_* extension fields carry what it does report, including x_m365_dea_score, the classifier score that rises before the Disengaged filter fires.

    Development

    pnpm install
    pnpm test          # 237 tests, no network and no credentials required
    pnpm typecheck
    pnpm build
    

    Tests run against a stub SignalR server (test/stub-copilot.ts) that speaks the real framing, so a full turn — including the mandatory Metrics frame, ping handling, Disengaged detection and the stop-on-abort frame — is exercised offline.

    No live M365 traffic has been run against this implementation. Everything above about opencode's behaviour is measured; everything about M365's behaviour is implemented from the protocol notes in cramt/m365-copilot-proxy (MIT), whose docs/ are the source of truth for this surface. Run opencode-m365 doctor against your own tenant before trusting it.

    Credit

    The protocol this implements was reverse-engineered by cramt/m365-copilot-proxy. This is an independent implementation written against that project's documentation, not a fork of its code.

    Licence

    MIT. This speaks to Microsoft's API with your own credentials on your own account — whether that is acceptable is between you and your tenant's acceptable-use policy.

    同类生态推荐