diff --git a/changelog.d/features/7872-m365-tone-model-variants.md b/changelog.d/features/7872-m365-tone-model-variants.md new file mode 100644 index 0000000000..5492efae1d --- /dev/null +++ b/changelog.d/features/7872-m365-tone-model-variants.md @@ -0,0 +1 @@ +- feat(providers): copilot-m365-web tone-selected model variants (#7872) diff --git a/open-sse/config/providers/registry/copilot-m365-web/index.ts b/open-sse/config/providers/registry/copilot-m365-web/index.ts index 98a288fa4b..c5baab9166 100644 --- a/open-sse/config/providers/registry/copilot-m365-web/index.ts +++ b/open-sse/config/providers/registry/copilot-m365-web/index.ts @@ -8,5 +8,14 @@ export const copilot_m365_webProvider: RegistryEntry = { baseUrl: "wss://substrate.office.com/m365Copilot/Chathub", authType: "apikey", authHeader: "cookie", - models: [{ id: "copilot-m365", name: "Microsoft 365 Copilot (BizChat)", toolCalling: false }], + models: [ + { id: "copilot-m365", name: "Microsoft 365 Copilot (BizChat)", toolCalling: false }, + { id: "copilot-m365-claude-opus", name: "Microsoft 365 Copilot — Claude Opus", toolCalling: false }, + { + id: "copilot-m365-gpt-5-6-reasoning", + name: "Microsoft 365 Copilot — GPT 5.6 Reasoning", + toolCalling: false, + }, + { id: "copilot-m365-gpt-5-5-chat", name: "Microsoft 365 Copilot — GPT 5.5 Chat", toolCalling: false }, + ], }; diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts index 33074c069f..c15782e756 100644 --- a/open-sse/executors/copilot-m365-frames.ts +++ b/open-sse/executors/copilot-m365-frames.ts @@ -193,6 +193,29 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { }; } +/** + * BizChat exposes several models selected by the `tone` field of the `type:4` chat + * invocation (#7872, values confirmed against a real enterprise tenant in #7850). Each + * tone-selected variant is registered as its own model id; the bare `copilot-m365` id is + * intentionally absent here so it keeps the tier default tone (`Magic` on enterprise, `""` + * otherwise) resolved by {@link resolveChatInvocationOverrides}. + */ +export const M365_MODEL_TONE_MAP: Readonly> = { + "copilot-m365-claude-opus": "Claude_Opus", + "copilot-m365-gpt-5-6-reasoning": "Gpt_5_6_Reasoning", + "copilot-m365-gpt-5-5-chat": "Gpt_5_5_Chat", +}; + +/** + * Resolve the `tone` for a requested model id, or `undefined` when the id is the bare + * `copilot-m365` / unknown — callers then fall back to the tier default tone. Model-driven + * tone takes precedence over the tier default (see the executor wiring). + */ +export function resolveToneForModel(model: string | undefined): string | undefined { + if (!model) return undefined; + return M365_MODEL_TONE_MAP[model]; +} + /** * Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated). * Mirrors the argument shape captured on the individual M365 path in #4042. diff --git a/open-sse/executors/copilot-m365-web.ts b/open-sse/executors/copilot-m365-web.ts index 36cd186eb5..7e85c43e84 100644 --- a/open-sse/executors/copilot-m365-web.ts +++ b/open-sse/executors/copilot-m365-web.ts @@ -20,6 +20,7 @@ import { keepaliveFrame, parseFrame, resolveChatInvocationOverrides, + resolveToneForModel, splitFrames, } from "./copilot-m365-frames.ts"; @@ -167,6 +168,9 @@ export class CopilotM365WebExecutor extends BaseExecutor { const sendChat = () => { ws?.send(keepaliveFrame()); const overrides = resolveChatInvocationOverrides(input.tier); + // Model-driven tone (#7872) wins over the tier default; a bare/unknown id + // keeps the tier tone resolved above. + const tone = resolveToneForModel(input.model) ?? overrides.tone; ws?.send( encodeFrame( buildChatInvocation({ @@ -175,6 +179,7 @@ export class CopilotM365WebExecutor extends BaseExecutor { sessionId, isStartOfSession: true, ...overrides, + tone, }) ) ); diff --git a/tests/unit/m365-tone-model-variants.test.ts b/tests/unit/m365-tone-model-variants.test.ts new file mode 100644 index 0000000000..741ecd73a6 --- /dev/null +++ b/tests/unit/m365-tone-model-variants.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + M365_MODEL_TONE_MAP, + resolveToneForModel, + resolveChatInvocationOverrides, +} from "../../open-sse/executors/copilot-m365-frames.ts"; +import { copilot_m365_webProvider } from "../../open-sse/config/providers/registry/copilot-m365-web/index.ts"; + +// #7872 — tone-selected model variants for copilot-m365-web. +// The wiring (model id → tone → invocation payload) is unit-tested here; whether a tone +// actually selects that model upstream is a live enterprise-tenant check (release-drain). + +test("resolveToneForModel maps each variant id to its confirmed tone", () => { + assert.equal(resolveToneForModel("copilot-m365-claude-opus"), "Claude_Opus"); + assert.equal(resolveToneForModel("copilot-m365-gpt-5-6-reasoning"), "Gpt_5_6_Reasoning"); + assert.equal(resolveToneForModel("copilot-m365-gpt-5-5-chat"), "Gpt_5_5_Chat"); +}); + +test("resolveToneForModel returns undefined for the bare id and unknown ids", () => { + // bare id must fall back to the tier default, not a hard-coded tone + assert.equal(resolveToneForModel("copilot-m365"), undefined); + assert.equal(resolveToneForModel("totally-unknown"), undefined); + assert.equal(resolveToneForModel(undefined), undefined); + assert.equal(resolveToneForModel(""), undefined); +}); + +test("model-driven tone overrides the tier default; bare id keeps the tier tone", () => { + const enterprise = resolveChatInvocationOverrides("enterprise"); + const individual = resolveChatInvocationOverrides(undefined); + + // enterprise tier default tone is Magic + assert.equal(enterprise.tone, "Magic"); + assert.equal(individual.tone, ""); + + // precedence: resolveToneForModel(model) ?? overrides.tone (mirrors the executor wiring) + const toneFor = (model: string | undefined, tierTone: string) => + resolveToneForModel(model) ?? tierTone; + + // a variant id wins over BOTH tier defaults + assert.equal(toneFor("copilot-m365-claude-opus", enterprise.tone), "Claude_Opus"); + assert.equal(toneFor("copilot-m365-claude-opus", individual.tone), "Claude_Opus"); + + // the bare id keeps whatever the tier resolved + assert.equal(toneFor("copilot-m365", enterprise.tone), "Magic"); + assert.equal(toneFor("copilot-m365", individual.tone), ""); +}); + +test("registry exposes the bare id (first) plus every tone variant", () => { + const ids = copilot_m365_webProvider.models.map((m) => m.id); + assert.equal(ids[0], "copilot-m365", "bare Auto/default id must be first"); + for (const variantId of Object.keys(M365_MODEL_TONE_MAP)) { + assert.ok(ids.includes(variantId), `registry missing variant ${variantId}`); + } + // no duplicate ids + assert.equal(ids.length, new Set(ids).size); +});