Compare commits

..

4 Commits

Author SHA1 Message Date
Xiangzhe
a763666c3a chore(skills): regenerate omni-webhooks after provider discovery changes (#11060) 2026-08-22 14:13:55 -03:00
Xiangzhe
b4d7433253 fix(providers): perplexity import filters to Sonar-family models (#11060) 2026-08-22 13:13:32 -03:00
N123 Project
6cd4d38e21 fix(m365): BizChat invocation shape drift + HAR-import UX + Antigravity alias note (#11069)
5 — M365 Copilot (BizChat) individual/consumer path — 3 itens: (1) forma de invocação do #10718 derivou de novo (2026-08-21 capture): optionsSets 14→34, allowedMessageTypes 6→30, tone "magic"→"Magic", plugins []→[{BingWebSearch}], disconnectBehavior em todos os tiers, +8 keys de clientInfo; verificado contra conta real com round-trip WebSocket (ping-then-close → resposta real). (2) Aviso sobre o alias Antigravity gemini-3.1-pro-high ainda não publicado (3.8.49 pré-data). (3) Botão "Import .har file" no modal de credencial M365.

Conflito resolvido em copilot-m365-frames.ts (board vs release tip): mantive o forwarding de opts.plugins/toolChoice/customInstructions do HEAD com os NOVOS defaults da captura (BingWebSearch builtin, tone "Magic"). Alinhei 3 testes pré-existentes que afirmavam o contrato antigo (m365-bizchat-frames-4042 clientInfo, m365-tone-model-variants tone, copilot-m365-tool-calls plugins) — propagação de contrato, não mascaramento. Rebaselinei AddApiKeyModal 1073→1080 (crescimento próprio da parte 3, ~Har import button) com anotação.

Validação: typecheck limpo, 142/142 testes m365/copilot verdes, changelog-integrit/file-size/eslint OK.
2026-08-21 22:32:19 -03:00
Diego Rodrigues de Sa e Souza
b6412c6fed fix(command-code): use the documented /provider/v1 chat endpoint (#10265) (#11072)
5 — Fecha #10265: chat do command-code migra do endpoint CLI-only /alpha/generate (version-gated + proxy-blocked para callers externos) para o documentado /provider/v1/chat/completions (OpenAI format). Removido o envelope CLI reverse-engineered (config/memory/taste/skills + headers CLI-impersonation), substituído por passthrough OpenAI plano com normalização de model id vendor-prefixed (#10809), clamp de max_tokens (#5166), sanitização de reasoning_effort. commandCode.ts 1037→171 linhas.
Validado no worktree board sobre tip: typecheck:core limpo; 175/175 testes focados (command-code executor/vision/usage/maxtokens/user-array/validation-specialty/responses-handler/provider-models-scoping); changelog-integrity/file-size/complexity/cognitive todos OK. TDD RED→GREEN documentado.
2026-08-21 22:16:45 -03:00
19 changed files with 737 additions and 94 deletions

View File

@@ -0,0 +1 @@
- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060)

View File

@@ -443,7 +443,7 @@
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1250
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1073,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1080,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1298,
@@ -459,7 +459,8 @@
"open-sse/executors/commandCode.ts": 1059,
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming)."
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -30,17 +30,45 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const;
export const KEEPALIVE_PING = { type: 6 } as const;
/**
* Allowed message types observed in the 2026-08 recapture of the working
* `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer
* seen on the wire — the stale shape gets closed immediately after the type:4.
* Allowed message types observed in a 2026-08-21 live capture of a working
* `m365.cloud.microsoft/chat` session (issue: "Stream ended before producing a
* non-ping SSE event" on every individual/consumer M365 Copilot call). The
* #10718 6-entry shape above no longer produces a `type:1 target:"update"`
* frame at all — the socket only replies with SignalR keepalive pings and then
* closes, which is exactly what surfaces client-side as that generic stream
* error. 30 entries, up from 6.
*/
export const ALLOWED_MESSAGE_TYPES = [
"Chat",
"Suggestion",
"InternalSearchQuery",
"Disengaged",
"Progress",
"EndOfRequest",
"InternalLoaderMessage",
"Progress",
"GeneratedCode",
"RenderCardRequest",
"AdsQuery",
"SemanticSerp",
"GenerateContentQuery",
"GenerateGraphicArt",
"SearchQuery",
"ConfirmationCard",
"AuthError",
"DeveloperLogs",
"TriggerPlugin",
"HintInvocation",
"MemoryUpdate",
"EndOfRequest",
"TriggerConfirmation",
"ResumeInvokeAction",
"ResumeUserInputRequest",
"TriggerUserInputRequest",
"EscapeHatch",
"TriggerPluginAuth",
"ResumePluginAuth",
"SideBySide",
"ReferencesListComplete",
"SwitchRespondingEndpoint",
] as const;
/**
@@ -78,19 +106,26 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
] as const;
/**
* Individual / EDU option sets from the 2026-08 recapture (#10718)14 entries.
* The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*,
* …) is no longer observed on the wire and belongs to the shape the substrate
* now drops silently.
* Individual / EDU option sets from a 2026-08-21 live capture34 entries, up
* from the #10718 14-entry shape (which itself superseded an earlier 25-entry
* shape). Each recapture so far has been additive/reshuffled rather than a
* wholesale replacement — treat this as the protocol continuing to drift, not
* a one-time fix; a future capture may again need to update this list.
*/
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",
"cwcfluxgptv",
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
"gptvnorm2048",
"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",
@@ -98,6 +133,20 @@ export const M365_DEFAULT_OPTION_SETS = [
"flux_v3_progress_messages",
"enable_batch_token_processing",
"enable_gg_gpt",
"async_client_interaction",
"flux_v3_references",
"flux_v3_references_entities",
"flux_v3_references_ci",
"add_filestore_filetype",
"cwc_code_interpreter_citation_sourceannotations",
"cdxcwc_code_interpreter_hallucinated_url_filter",
"flux_v3_image_gen_enable_dimensions",
"flux_v3_image_gen_enable_non_watermarked_storage",
"flux_v3_image_gen_enable_icon_dimensions",
"flux_v3_image_gen_enable_system_text_with_params",
"flux_v3_image_gen_enable_designer_dimensions_meta_prompting_in_system_prompts",
"flux_v3_image_gen_enable_story",
"rich_responses",
] as const;
/** Append the record separator to a JSON-serializable frame. */
@@ -433,12 +482,14 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
}
return {
optionsSets: [...M365_DEFAULT_OPTION_SETS],
// #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the
// individual/EDU surface; the old "" default is part of the dropped shape.
tone: "magic",
// 2026-08-21 capture — the individual/consumer surface now sends "Magic"
// (capitalized), matching the enterprise tone literal. The #10718
// lowercase "magic" is part of the shape that gets silently dropped.
tone: "Magic",
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
disconnectBehavior: undefined,
// 2026-08-21 capture — disconnectBehavior:"continue" is now present on the
// individual/consumer wire too, not just enterprise (see ChatInvocationOptions).
disconnectBehavior: "continue",
};
}
@@ -467,16 +518,33 @@ export function resolveToneForModel(model: string | undefined): string | undefin
/**
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
* Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat`
* client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a
* populated `clientInfo` + `productThreadType:"Office"`, a `conversationId`
* matching the WS URL query, a rich `message` object, and no
* `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` /
* `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still
* observed on the wire, and the stale shape gets closed immediately after the
* invocation.
* Base shape from the #10718 recapture (populated `clientInfo` +
* `productThreadType:"Office"`, a `conversationId` matching the WS URL query, a
* rich `message` object), extended per a 2026-08-21 live capture that found the
* #10718 shape alone no longer produces a `type:1 target:"update"` frame — the
* socket only replies with keepalive pings and closes. The additions below
* (richer `clientInfo`, non-empty `plugins`, `extraExtensionParameters`,
* `isSbsSupported`, `renderReferencesBehindEOS`,
* `message.connectedFederatedConnections`, and `disconnectBehavior` on every
* tier) are exactly the fields the 2026-08-21 capture had that this shape was
* missing; the #10718 fields (`conversationId`, `productThreadType`,
* `toolChoice`, `message.attachments`) are kept as-is since removing them was
* not verified against a live socket.
*/
export function buildChatInvocation(opts: ChatInvocationOptions): Record<string, unknown> {
const clientInfo = {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
clientEntrypoint: "mcmcopilot-officeweb",
clientSessionId: opts.sessionId,
ProductCategory: "Chat",
clientAppType: "Web",
productEntryPoint: "ChatPanel",
deviceOS: "Windows",
deviceType: "Desktop",
clientPlatformVersion: "10",
};
return {
type: 4,
target: "chat",
@@ -487,17 +555,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
? [...opts.allowedMessageTypes]
: [...ALLOWED_MESSAGE_TYPES],
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
clientInfo: {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
},
clientInfo,
conversationId: opts.conversationId,
extraExtensionParameters: {},
isStartOfSession: opts.isStartOfSession ?? true,
message: {
adaptiveCards: [],
attachments: null,
author: "user",
clientInfo,
clientPreferences: {},
connectedFederatedConnections: ["dummyId"],
entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"],
experienceType: "Default",
inputMethod: "Keyboard",
@@ -510,22 +578,27 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
requestId: opts.requestId,
text: opts.text,
},
isSbsSupported: true,
options: {},
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
plugins: opts.plugins ?? [],
// 2026-08-21 capture (#11069): BingWebSearch is now the universal
// BuiltIn plugin on individual/consumer tier; keep an opt-out override.
plugins: opts.plugins ?? [{ Id: "BingWebSearch", Source: "BuiltIn" }],
...(opts.customInstructions ? { customInstructions: opts.customInstructions } : {}),
productThreadType: "Office",
renderReferencesBehindEOS: true,
sessionId: opts.sessionId,
sliceIds: [],
source: "officeweb",
streamingMode: "ConciseWithPadding",
threadLevelGptId: {},
tone: opts.tone ?? "magic",
// 2026-08-21 capture (#11069): tone is now capitalized "Magic" on both tiers.
tone: opts.tone ?? "Magic",
toolChoice: opts.toolChoice ?? null,
traceId: opts.traceId,
// #8971 keeps "continue" for the enterprise tier; the individual/EDU wire
// omits the key, so only include it when actually set (#10718).
...(opts.disconnectBehavior ? { disconnectBehavior: opts.disconnectBehavior } : {}),
// 2026-08-21 capture — disconnectBehavior:"continue" is sent on every
// tier now, not gated to enterprise as the #8971 comment described.
disconnectBehavior: opts.disconnectBehavior ?? "continue",
},
],
};

View File

@@ -1,12 +1,12 @@
---
name: omni-webhooks
description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
---
<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->
## Overview
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
## Authentication

View File

@@ -12,8 +12,13 @@ import {
} from "@/lib/combos/intelligentRouting";
import { AI_PROVIDERS } from "@/shared/constants/providers";
function getI18nOrFallback(t: any, key: string, fallback: string) {
if (typeof t?.has === "function" && t.has(key)) return t(key);
function getI18nOrFallback(
t: any,
key: string,
fallback: string,
values?: Record<string, unknown>
) {
if (typeof t?.has === "function" && t.has(key)) return t(key, values);
return fallback;
}
@@ -94,10 +99,9 @@ export default function IntelligentComboPanel({
const updatedCombo = await response.json();
onComboUpdated?.(updatedCombo);
notify.success(
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace(
"{pack}",
modePackId
)
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.", {
pack: modePackId,
}).replace("{pack}", modePackId)
);
} catch (error: any) {
notify.error(error?.message || "Failed to update mode pack.");
@@ -184,10 +188,9 @@ export default function IntelligentComboPanel({
</div>
{savingModePack && (
<span className="text-[11px] text-text-muted">
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
"{pack}",
savingModePack
)}
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…", {
pack: savingModePack,
}).replace("{pack}", savingModePack)}
</span>
)}
</div>

View File

@@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) {
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
}
function getI18nOrFallback(t, key, fallback) {
function getI18nOrFallback(t, key, fallback, values) {
try {
if (typeof t.has === "function" && t.has(key)) return t(key);
if (typeof t.has === "function" && t.has(key)) return t(key, values);
} catch {}
return fallback;
}
@@ -1565,7 +1565,8 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
{getI18nOrFallback(
t,
"recommendationsUpdated",
"Recommendations updated for {strategy}."
"Recommendations updated for {strategy}.",
{ strategy: strategyLabel }
).replace("{strategy}", strategyLabel)}
</div>
)}

View File

@@ -0,0 +1,165 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
extractM365CredentialFromHar,
describeHarImportExpiry,
type M365HarImportResult,
} from "@/shared/utils/m365HarImport";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
type HarImporter = (text: string) => M365HarImportResult;
// One entry per web-session provider that can offer HAR import. Add a new
// key here (and its own extractor in src/shared/utils/) to support another
// provider — the button renders nothing for any provider not listed.
const HAR_IMPORTERS: Record<string, HarImporter> = {
"copilot-m365-web": extractM365CredentialFromHar,
};
const ERROR_MESSAGE_KEYS: Record<string, [string, string]> = {
notJson: ["harImportErrorNotJson", "That file isn't valid JSON — is it really a .har export?"],
noEntries: ["harImportErrorNoEntries", "This HAR has no network entries recorded."],
noChathubUrl: [
"harImportErrorNoChathubUrl",
"No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
],
unparsableUrl: [
"harImportErrorUnparsableUrl",
"Found the chat connection, but couldn't read its URL.",
],
missingFields: [
"harImportErrorMissingFields",
"Found the chat connection, but the token was missing from it.",
],
};
export interface HarImportButtonProps {
provider: string;
onImport: (apiKey: string) => void;
}
export default function HarImportButton({ provider, onImport }: HarImportButtonProps) {
const t = useTranslations("providers") as ProviderMessageTranslator;
const importer = HAR_IMPORTERS[provider];
const fileInputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<
| { phase: "idle" }
| { phase: "reading" }
| { phase: "error"; message: string }
| { phase: "success"; expiresAt: number | null }
>({ phase: "idle" });
if (!importer) return null;
async function handleFile(file: File | undefined) {
if (!file) return;
setState({ phase: "reading" });
let text: string;
try {
text = await file.text();
} catch {
setState({
phase: "error",
message: providerText(t, "harImportErrorReadFailed", "Couldn't read that file."),
});
return;
}
const result = importer(text);
if (!result.ok) {
const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [
"harImportErrorUnknown",
"Couldn't extract a credential from that HAR file.",
];
setState({ phase: "error", message: providerText(t, key, fallback) });
return;
}
onImport(result.apiKey);
setState({ phase: "success", expiresAt: result.expiresAt });
}
const expiry = state.phase === "success" ? describeHarImportExpiry(state.expiresAt) : null;
const expiryText =
expiry?.tone === "unknown"
? providerText(t, "harImportStatusUnknownExpiry", "Imported. Couldn't read its expiry.")
: expiry?.tone === "bad"
? providerText(
t,
"harImportStatusExpired",
"Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
{ minutes: Math.abs(expiry.minutesRemaining ?? 0) }
)
: expiry?.tone === "warn"
? providerText(
t,
"harImportStatusExpiringSoon",
"Imported — valid for only ~{minutes}m more.",
{ minutes: expiry.minutesRemaining ?? 0 }
)
: expiry?.tone === "ok"
? providerText(t, "harImportStatusValid", "Imported — valid for ~{minutes}m.", {
minutes: expiry.minutesRemaining ?? 0,
})
: null;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={state.phase === "reading"}
data-testid="har-import-button"
className="inline-flex items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-xs font-medium text-text-main hover:bg-surface-hover disabled:opacity-50"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
upload_file
</span>
{state.phase === "reading"
? providerText(t, "harImportButtonBusy", "Importing…")
: providerText(t, "harImportButtonLabel", "Import .har file")}
</button>
<span className="text-xs text-text-muted">
{providerText(
t,
"harImportButtonHint",
"Export from DevTools Network tab after sending at least one chat message."
)}
</span>
<input
ref={fileInputRef}
type="file"
accept=".har,application/json"
data-testid="har-import-input"
className="hidden"
onChange={(event) => {
void handleFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
</div>
{state.phase === "error" && (
<p className="text-xs text-red-600 dark:text-red-400" data-testid="har-import-error">
{state.message}
</p>
)}
{state.phase === "success" && expiryText && (
<p
className={
expiry?.tone === "bad"
? "text-xs text-red-600 dark:text-red-400"
: expiry?.tone === "warn"
? "text-xs text-amber-700 dark:text-amber-300"
: "text-xs text-emerald-700 dark:text-emerald-300"
}
data-testid="har-import-status"
>
{expiryText}
</p>
)}
</div>
);
}

View File

@@ -31,6 +31,7 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase";
@@ -209,13 +210,13 @@ export default function AddApiKeyModal({
? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester."
: isWebSessionCredential
? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false)
: isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "",
})
: apiKeyOptional
? t("apiKeyOptionalHint")
: undefined;
: isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "",
})
: apiKeyOptional
? t("apiKeyOptionalHint")
: undefined;
const credentialValidationFailedMessage = isWebSessionCredential
? providerText(
t,
@@ -750,6 +751,12 @@ export default function AddApiKeyModal({
t={t}
/>
)}
{provider && (
<HarImportButton
provider={provider}
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
/>
)}
{!isNoAuthWebSessionCredential && (
<div className="flex gap-2">
<Input

View File

@@ -49,6 +49,7 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { CodexConnectionFields } from "./CodexFingerprintFields";
import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData";
@@ -909,6 +910,12 @@ export default function EditConnectionModal({
t={t}
/>
)}
{provider && (
<HarImportButton
provider={provider}
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
/>
)}
{!isNoAuthWebSessionCredential && (
<div className="flex gap-2">
<Input

View File

@@ -87,6 +87,22 @@ export function parseAlibabaModelStudioModelsForConnection(
export function parseQwenCloudTextModels(data: any): any[] {
return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS);
}
// Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed ids like
// "anthropic/claude-fable-5"), but chat requests always go to the classic
// /chat/completions endpoint, which only accepts the Sonar family. Filter
// discovery to Sonar-family ids so agent-style ids never surface as routable
// chat models (#11060). Bounded pattern — no ReDoS-prone quantifiers.
export function parsePerplexitySonarModels(data: any): any[] {
const models = Array.isArray(data?.data)
? data.data
: Array.isArray(data?.models)
? data.models
: [];
return models.filter(
(model: any) => typeof model?.id === "string" && /^sonar(-|$)/.test(model.id)
);
}
type ProviderModelsHeaderContext = {
authType?: string;
providerSpecificData?: unknown;
@@ -659,6 +675,17 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
headers: { Accept: "application/json" },
parseResponse: parseClinepassRecommendedModels,
},
// Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed agent
// ids), but chat only accepts the Sonar family on /chat/completions. Import
// must keep Sonar-family ids only (#11060).
perplexity: {
url: "https://api.perplexity.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: parsePerplexitySonarModels,
},
cohere: {
url: "https://api.cohere.com/v2/models",
method: "GET",

View File

@@ -6269,6 +6269,20 @@
"webSessionGuideStep3": "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.",
"webSessionGuideStep3Manual": "Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix.",
"webSessionGuideStep4": "Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value.",
"harImportButtonLabel": "Import .har file",
"harImportButtonBusy": "Importing…",
"harImportButtonHint": "Export from DevTools Network tab after sending at least one chat message.",
"harImportStatusValid": "Imported — valid for ~{minutes}m.",
"harImportStatusExpiringSoon": "Imported — valid for only ~{minutes}m more.",
"harImportStatusExpired": "Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
"harImportStatusUnknownExpiry": "Imported. Couldn't read its expiry.",
"harImportErrorNotJson": "That file isn't valid JSON — is it really a .har export?",
"harImportErrorNoEntries": "This HAR has no network entries recorded.",
"harImportErrorNoChathubUrl": "No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
"harImportErrorUnparsableUrl": "Found the chat connection, but couldn't read its URL.",
"harImportErrorMissingFields": "Found the chat connection, but the token was missing from it.",
"harImportErrorReadFailed": "Couldn't read that file.",
"harImportErrorUnknown": "Couldn't extract a credential from that HAR file.",
"webSessionSecurityHint": "Treat this like a password: it may access your signed-in web account until it expires or is revoked.",
"webNoAuthGuideTitle": "No credential required",
"webNoAuthGuideBody": "{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.",

View File

@@ -0,0 +1,116 @@
/**
* Pure, transport-free helpers that extract a `copilot-m365-web` credential
* (`access_token=...; chathubPath=...`) directly from a DevTools HAR export,
* so the "Add connection" / "Edit connection" dialogs can offer a one-click
* "Import .har file" button instead of the user hand-extracting the token
* from the WebSocket URL (see `HarImportButton.tsx`).
*
* The token rides in the query string of the BizChat ChatHub WebSocket URL
* (`wss://substrate.office.com/m365Copilot/Chathub/<oid>@<tenant>?...
* access_token=...`) — the same URL the executor itself connects to (see
* `open-sse/executors/copilot-m365-connection.ts`). No network calls here;
* everything operates on the HAR text already in the browser.
*/
/** Prefix identifying the BizChat ChatHub WebSocket request in a HAR entry. */
export const M365_CHATHUB_WS_PREFIX = "wss://substrate.office.com/m365Copilot/Chathub/";
export type M365HarImportResult =
| { ok: true; apiKey: string; chathubPath: string; expiresAt: number | null }
| { ok: false; error: string };
interface HarEntryLike {
request?: { url?: unknown };
}
interface HarLike {
log?: { entries?: unknown };
}
/** Decode a JWT payload WITHOUT verification — exp is a display hint only. */
function decodeJwtExpiry(token: string): number | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const json = JSON.parse(atob(padded)) as { exp?: unknown };
return typeof json.exp === "number" ? json.exp * 1000 : null;
} catch {
return null;
}
}
/**
* Extract the `copilot-m365-web` credential from raw HAR file text.
*
* Scans every request in `log.entries` for the ChatHub WebSocket URL and
* uses the LAST match (a HAR may contain several turns of the same session;
* the most recent one carries the freshest token). Returns a structured
* error — never throws — so callers can render it directly.
*/
export function extractM365CredentialFromHar(text: string): M365HarImportResult {
let har: HarLike;
try {
har = JSON.parse(text) as HarLike;
} catch {
return { ok: false, error: "notJson" };
}
const entries = har.log?.entries;
if (!Array.isArray(entries)) {
return { ok: false, error: "noEntries" };
}
let matchedUrl: string | null = null;
for (const entry of entries as HarEntryLike[]) {
const url = entry?.request?.url;
if (typeof url === "string" && url.startsWith(M365_CHATHUB_WS_PREFIX)) {
matchedUrl = url;
}
}
if (!matchedUrl) {
return { ok: false, error: "noChathubUrl" };
}
let parsed: URL;
try {
parsed = new URL(matchedUrl);
} catch {
return { ok: false, error: "unparsableUrl" };
}
const token = parsed.searchParams.get("access_token");
const chathubPath = decodeURIComponent(parsed.pathname.split("/m365Copilot/Chathub/")[1] || "");
if (!token || !chathubPath) {
return { ok: false, error: "missingFields" };
}
return {
ok: true,
apiKey: `access_token=${token}; chathubPath=${chathubPath}`,
chathubPath,
expiresAt: decodeJwtExpiry(token),
};
}
export type HarImportExpiryTone = "ok" | "warn" | "bad" | "unknown";
export interface HarImportExpiryStatus {
tone: HarImportExpiryTone;
minutesRemaining: number | null;
}
/** Classify a decoded token expiry for the inline status hint. */
export function describeHarImportExpiry(
expiresAt: number | null,
now: number = Date.now()
): HarImportExpiryStatus {
if (expiresAt === null) return { tone: "unknown", minutesRemaining: null };
const minutesRemaining = Math.round((expiresAt - now) / 60000);
if (minutesRemaining <= 0) return { tone: "bad", minutesRemaining };
if (minutesRemaining < 15) return { tone: "warn", minutesRemaining };
return { tone: "ok", minutesRemaining };
}

View File

@@ -15,7 +15,10 @@ class MockM365WebSocket {
closed = false;
listeners = new Map<string, Listener[]>();
constructor(public url: string, public options: unknown) {
constructor(
public url: string,
public options: unknown
) {
MockM365WebSocket.instances.push(this);
queueMicrotask(() => this.emit("open"));
}
@@ -144,30 +147,55 @@ test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () =
assert.equal(invocationArgs.tone, "Magic");
});
test("#10718: individual (no tier) chat invocation carries the recaptured 2026-08 shape", async () => {
test("2026-08-21: individual (no tier) chat invocation carries the recaptured shape", async () => {
const invocationArgs = await sendChatInvocation(undefined);
const optionsSets = invocationArgs.optionsSets as string[];
// The 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is gone from
// the wire — the stale set was part of the silently-dropped shape.
// The pre-#10718 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is
// still gone from the wire — only the 2026-08-21 34-entry set is current.
assert.ok(!optionsSets.includes("enable_msa_user"));
assert.ok(optionsSets.includes("enable_gg_gpt"));
// The browser sends tone:"magic" (lowercase) on the individual/EDU surface.
assert.equal(invocationArgs.tone, "magic");
// The individual/consumer surface now sends tone:"Magic" (capitalized) —
// the #10718 lowercase "magic" is stale.
assert.equal(invocationArgs.tone, "Magic");
assert.deepEqual(invocationArgs.allowedMessageTypes, [
"Chat",
"Suggestion",
"InternalSearchQuery",
"Disengaged",
"Progress",
"EndOfRequest",
"InternalLoaderMessage",
"Progress",
"GeneratedCode",
"RenderCardRequest",
"AdsQuery",
"SemanticSerp",
"GenerateContentQuery",
"GenerateGraphicArt",
"SearchQuery",
"ConfirmationCard",
"AuthError",
"DeveloperLogs",
"TriggerPlugin",
"HintInvocation",
"MemoryUpdate",
"EndOfRequest",
"TriggerConfirmation",
"ResumeInvokeAction",
"ResumeUserInputRequest",
"TriggerUserInputRequest",
"EscapeHatch",
"TriggerPluginAuth",
"ResumePluginAuth",
"SideBySide",
"ReferencesListComplete",
"SwitchRespondingEndpoint",
]);
});
test("#10718: EDU-tier chat invocation carries the same recaptured shape", async () => {
test("2026-08-21: EDU-tier chat invocation carries the same recaptured shape", async () => {
const invocationArgs = await sendChatInvocation("edu");
const optionsSets = invocationArgs.optionsSets as string[];
assert.ok(!optionsSets.includes("enable_msa_user"));
assert.equal(invocationArgs.tone, "magic");
assert.equal(invocationArgs.tone, "Magic");
});
test("#8971: enterprise-tier chat invocation must send disconnectBehavior=continue", async () => {
@@ -179,11 +207,11 @@ test("#8971: enterprise-tier chat invocation must send disconnectBehavior=contin
);
});
test("#8971/#10718: individual (no tier) chat invocation omits disconnectBehavior (not on the 2026-08 wire)", async () => {
test("2026-08-21: individual (no tier) chat invocation also sends disconnectBehavior=continue (now on every tier)", async () => {
const invocationArgs = await sendChatInvocation(undefined);
assert.equal(
invocationArgs.disconnectBehavior,
undefined,
`individual-tier invocation must omit disconnectBehavior; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
"continue",
`individual-tier invocation must carry disconnectBehavior="continue"; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
);
});

View File

@@ -41,7 +41,7 @@ test("#10718: metricsFrame emits the exact bytes observed in the browser capture
// ── Recaptured invocation shape ────────────────────────────────────────────
test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", () => {
test("2026-08-21: buildChatInvocation matches the recaptured arguments[0] key set", () => {
const arg = buildChatInvocation({
text: "Say OK in one word.",
traceId: "11111111-1111-1111-1111-111111111111",
@@ -50,19 +50,25 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set",
conversationId: "44444444-4444-4444-4444-444444444444",
}).arguments[0] as Record<string, unknown>;
// Exact key set from the capture — additions AND omissions are both pinned,
// because the stale keys are exactly what got the shape dropped.
// Exact key set from the 2026-08-21 capture (issue: individual/consumer M365
// Copilot calls got only SignalR keepalive pings and no type:1 update at all
// — "Stream ended before producing a non-ping SSE event" client-side). The
// #10718 shape below is missing exactly the keys this capture added.
assert.deepEqual(Object.keys(arg).sort(), [
"allowedMessageTypes",
"clientCorrelationId",
"clientInfo",
"conversationId",
"disconnectBehavior",
"extraExtensionParameters",
"isSbsSupported",
"isStartOfSession",
"message",
"options",
"optionsSets",
"plugins",
"productThreadType",
"renderReferencesBehindEOS",
"sessionId",
"sliceIds",
"source",
@@ -73,13 +79,32 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set",
"traceId",
]);
assert.equal(arg.productThreadType, "Office");
assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
assert.deepEqual(arg.clientInfo, {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
clientEntrypoint: "mcmcopilot-officeweb",
clientSessionId: "22222222-2222-2222-2222-222222222222",
ProductCategory: "Chat",
clientAppType: "Web",
productEntryPoint: "ChatPanel",
deviceOS: "Windows",
deviceType: "Desktop",
clientPlatformVersion: "10",
});
assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444");
assert.equal(arg.toolChoice, null);
assert.equal(arg.tone, "magic");
// The individual/consumer surface now sends "Magic" (capitalized), matching
// the enterprise tone literal — the #10718 lowercase "magic" is stale.
assert.equal(arg.tone, "Magic");
assert.equal(arg.isSbsSupported, true);
assert.equal(arg.renderReferencesBehindEOS, true);
assert.deepEqual(arg.extraExtensionParameters, {});
assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]);
// Sent on every tier now, not gated to enterprise as #8971 described.
assert.equal(arg.disconnectBehavior, "continue");
});
test("#10718: the message object carries the recaptured rich shape", () => {
test("2026-08-21: the message object carries the recaptured rich shape", () => {
const arg = buildChatInvocation({
text: "Say OK in one word.",
traceId: "t",
@@ -93,7 +118,9 @@ test("#10718: the message object carries the recaptured rich shape", () => {
"adaptiveCards",
"attachments",
"author",
"clientInfo",
"clientPreferences",
"connectedFederatedConnections",
"entityAnnotationTypes",
"experienceType",
"inputMethod",
@@ -107,26 +134,41 @@ test("#10718: the message object carries the recaptured rich shape", () => {
assert.equal(message.messageType, "Chat");
assert.equal(message.requestId, "r");
assert.equal(message.experienceType, "Default");
assert.deepEqual(message.entityAnnotationTypes, ["People", "File", "Event", "Email", "TeamsMessage"]);
assert.deepEqual(message.entityAnnotationTypes, [
"People",
"File",
"Event",
"Email",
"TeamsMessage",
]);
assert.equal(message.attachments, null);
assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 });
assert.deepEqual(message.connectedFederatedConnections, ["dummyId"]);
// Same clientInfo object echoed inside message, per the capture.
assert.deepEqual(message.clientInfo, arg.clientInfo);
});
test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-entry allowedMessageTypes", () => {
test("2026-08-21: default tier lists are the recaptured 34-entry optionsSets / 30-entry allowedMessageTypes", () => {
const overrides = resolveChatInvocationOverrides(undefined);
assert.equal(overrides.optionsSets.length, 14);
assert.equal(overrides.allowedMessageTypes.length, 6);
assert.equal(overrides.tone, "magic");
// The pre-#10718 consumer/MSA flags are gone from the wire.
assert.equal(overrides.optionsSets.length, 34);
assert.equal(overrides.allowedMessageTypes.length, 30);
assert.equal(overrides.tone, "Magic");
assert.equal(overrides.disconnectBehavior, "continue");
const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[];
const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[];
for (const stale of ["enable_msa_user", "pdnascan", "cwc_code_interpreter", "rich_responses"]) {
assert.ok(!optionSets.includes(stale), `${stale} must not be in the default option sets`);
// Entries the 2026-08-21 capture showed that the #10718 lists lacked.
for (const present of [
"cwc_code_interpreter",
"rich_responses",
"async_client_interaction",
"flux_v3_references",
]) {
assert.ok(optionSets.includes(present), `${present} must be in the default option sets`);
}
for (const stale of ["InternalSearchQuery", "GeneratedCode", "RenderCardRequest", "AdsQuery", "SemanticSerp", "GenerateContentQuery"]) {
assert.ok(!messageTypes.includes(stale), `${stale} must not be in allowedMessageTypes`);
for (const present of ["InternalSearchQuery", "GeneratedCode", "AuthError", "TriggerPlugin"]) {
assert.ok(messageTypes.includes(present), `${present} must be in allowedMessageTypes`);
}
// Entries the capture showed and the old lists lacked.
// Entries the #10718 capture showed and this capture still confirms.
assert.ok(optionSets.includes("cwcfluxgptv"));
assert.ok(messageTypes.includes("EndOfRequest"));
});
@@ -134,8 +176,7 @@ test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-ent
// ── refresh_token helpers ──────────────────────────────────────────────────
function fakeJwt(claims: Record<string, unknown>): string {
const b64 = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${b64({ alg: "none" })}.${b64(claims)}.sig`;
}

View File

@@ -182,7 +182,9 @@ test("buildChatInvocation defaults stay backward compatible", () => {
requestId: "r",
conversationId: "c",
}).arguments[0] as Record<string, unknown>;
assert.deepEqual(arg.plugins, []);
// 2026-08-21 capture (#11069): BingWebSearch BuiltIn plugin is now the
// default on all tiers (was [] in the #10718 shape); toolChoice stays null.
assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]);
assert.equal(arg.toolChoice, null);
assert.equal(arg.customInstructions, undefined);
});

View File

@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
extractM365CredentialFromHar,
describeHarImportExpiry,
M365_CHATHUB_WS_PREFIX,
} from "../../../src/shared/utils/m365HarImport.ts";
function fakeJwt(exp: number): string {
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${b64({ alg: "none" })}.${b64({ exp })}.sig`;
}
function harWithUrl(url: string | null): string {
return JSON.stringify({
log: { entries: url ? [{ request: { url } }] : [] },
});
}
test("extracts access_token + chathubPath from a matching ChatHub WS entry", () => {
const exp = Math.floor(Date.now() / 1000) + 3600;
const token = fakeJwt(exp);
const url = `${M365_CHATHUB_WS_PREFIX}oid-123%40tenant-456?chatsessionid=abc&access_token=${token}`;
const result = extractM365CredentialFromHar(harWithUrl(url));
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.chathubPath, "oid-123@tenant-456");
assert.equal(result.apiKey, `access_token=${token}; chathubPath=oid-123@tenant-456`);
assert.ok(result.expiresAt !== null && Math.abs(result.expiresAt - exp * 1000) < 1000);
});
test("picks the LAST matching entry when a HAR has multiple turns", () => {
const oldToken = fakeJwt(1000);
const freshToken = fakeJwt(9999999999);
const har = JSON.stringify({
log: {
entries: [
{ request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${oldToken}` } },
{ request: { url: "https://unrelated.example/" } },
{ request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${freshToken}` } },
],
},
});
const result = extractM365CredentialFromHar(har);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.ok(result.apiKey.includes(freshToken));
});
test("returns notJson for malformed input", () => {
const result = extractM365CredentialFromHar("not json{{{");
assert.deepEqual(result, { ok: false, error: "notJson" });
});
test("returns noEntries when log.entries is missing/not an array", () => {
const result = extractM365CredentialFromHar(JSON.stringify({ log: {} }));
assert.deepEqual(result, { ok: false, error: "noEntries" });
});
test("returns noChathubUrl when no request matches the ChatHub prefix", () => {
const result = extractM365CredentialFromHar(harWithUrl("https://example.com/"));
assert.deepEqual(result, { ok: false, error: "noChathubUrl" });
});
test("returns missingFields when the URL matches but lacks access_token or chathubPath", () => {
const result = extractM365CredentialFromHar(harWithUrl(`${M365_CHATHUB_WS_PREFIX}`));
assert.equal(result.ok, false);
});
test("describeHarImportExpiry classifies ok/warn/bad/unknown", () => {
const now = Date.now();
assert.equal(describeHarImportExpiry(now + 30 * 60000, now).tone, "ok");
assert.equal(describeHarImportExpiry(now + 5 * 60000, now).tone, "warn");
assert.equal(describeHarImportExpiry(now - 60000, now).tone, "bad");
assert.equal(describeHarImportExpiry(null, now).tone, "unknown");
});

View File

@@ -131,7 +131,22 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te
assert.equal(arg.sessionId, "session-id");
assert.equal(arg.conversationId, "conversation-id");
assert.equal(arg.productThreadType, "Office");
assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
// 2026-08-21 capture (#11069): clientInfo gained 8 keys (clientEntrypoint,
// clientSessionId, ProductCategory, clientAppType, productEntryPoint,
// deviceOS, deviceType, clientPlatformVersion). The #10718 base shape only
// carried clientAppName + clientPlatform.
assert.deepEqual(arg.clientInfo, {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
clientEntrypoint: "mcmcopilot-officeweb",
clientSessionId: "session-id",
ProductCategory: "Chat",
clientAppType: "Web",
productEntryPoint: "ChatPanel",
deviceOS: "Windows",
deviceType: "Desktop",
clientPlatformVersion: "10",
});
assert.equal(arg.isStartOfSession, true);
assert.ok(Array.isArray(arg.optionsSets));
assert.ok((arg.optionsSets as string[]).includes("enable_gg_gpt"));

View File

@@ -30,10 +30,11 @@ 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; individual/EDU sends "magic" (#10718
// recapture — the old "" default was part of the silently-dropped shape)
// enterprise tier default tone is Magic; individual/EDU now also sends
// "Magic" (capitalized) — the 2026-08-21 capture (#11069) showed lowercase
// "magic" is part of the shape that gets silently dropped (ping-only socket).
assert.equal(enterprise.tone, "Magic");
assert.equal(individual.tone, "magic");
assert.equal(individual.tone, "Magic");
// precedence: resolveToneForModel(model) ?? overrides.tone (mirrors the executor wiring)
const toneFor = (model: string | undefined, tierTone: string) =>
@@ -43,9 +44,9 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone"
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
// the bare id keeps whatever the tier resolved (both "Magic" post-#11069)
assert.equal(toneFor("copilot-m365", enterprise.tone), "Magic");
assert.equal(toneFor("copilot-m365", individual.tone), "magic");
assert.equal(toneFor("copilot-m365", individual.tone), "Magic");
});
test("registry exposes the bare id (first) plus every tone variant", () => {

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
// Regression guard for #11060 — Perplexity's /v1/models endpoint lists the
// Agent API catalog (vendor-prefixed ids like "anthropic/claude-fable-5"), but
// chat requests always go to the classic /chat/completions endpoint, which only
// accepts the Sonar family. Without a PROVIDER_MODELS_CONFIG entry, generic
// model import pulled those agent-style ids into the connection's chat model
// list and every routed request failed with 400 "Invalid model". The discovery
// entry must exist and its parseResponse must keep only Sonar-family ids.
test("perplexity has a discovery entry in PROVIDER_MODELS_CONFIG", () => {
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
assert.ok(cfg, "expected a perplexity entry in PROVIDER_MODELS_CONFIG");
assert.equal(cfg.method, "GET");
assert.equal(cfg.url, "https://api.perplexity.ai/v1/models");
assert.equal(typeof cfg.parseResponse, "function");
});
test("perplexity parseResponse keeps only the Sonar family (#11060)", () => {
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
const models = cfg.parseResponse({
object: "list",
data: [
{ id: "anthropic/claude-fable-5", object: "model", owned_by: "anthropic" },
{ id: "sonar-pro", object: "model", owned_by: "perplexity" },
{ id: "sonar", object: "model", owned_by: "perplexity" },
],
}) as Array<{ id: string }>;
assert.deepEqual(
models.map((model) => model.id),
["sonar-pro", "sonar"]
);
});
test("perplexity parseResponse keeps every Sonar variant and drops non-Sonar ids", () => {
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
const models = cfg.parseResponse({
data: [
{ id: "sonar-deep-research" },
{ id: "sonar-reasoning-pro" },
{ id: "sonar-pro" },
{ id: "sonar" },
{ id: "openai/gpt-5" },
{ id: "sonarish" },
],
}) as Array<{ id: string }>;
assert.deepEqual(
models.map((model) => model.id),
["sonar-deep-research", "sonar-reasoning-pro", "sonar-pro", "sonar"]
);
});
test("perplexity parseResponse tolerates empty and malformed payloads", () => {
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
assert.deepEqual(cfg.parseResponse({ data: [] }), []);
assert.deepEqual(cfg.parseResponse(undefined), []);
assert.deepEqual(cfg.parseResponse({}), []);
});