From 5bacb719d32482135c79f206d07142c7f35e7a2e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:09:12 -0300 Subject: [PATCH] feat: add Microsoft Designer as image provider (#6672) (#7609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sse): add Microsoft Designer as image provider (#6672) Adds `microsoft-designer-web` — an unofficial, reverse-engineered Bearer-token web-session image provider, modeled on the existing `chatgpt-web`/`copilot-m365-web` "-web" provider category. - Registers the provider in WEB_COOKIE_PROVIDERS (src/shared/constants/ providers/web-cookie.ts) and IMAGE_PROVIDERS (open-sse/config/ imageRegistry.ts, new "designer-web" format). - New handler open-sse/handlers/imageGeneration/providers/designerWeb.ts implements the submit-then-poll DallE.ashx flow (Bearer access_token + ClientId/SessionId/UserId headers -> form POST -> poll for image_urls_thumbnail), wired into handleImageGeneration()'s dispatch. - The upstream ClientId header is a fixed, publicly-shared value (not a secret) — routed through resolvePublicCred() per Hard Rule #11, never as a string literal. - Registers the token-based credential requirement in webSessionCredentials.ts so the provider-connect UI asks for the right field; connection validation falls back to the existing generic web-cookie session-ping validator (no dedicated validator needed). - Extracted the KIE image-model catalog into a co-located open-sse/config/providers/registry/kie/models.ts module (mirrors the existing lmarena/directModels.ts pattern) to keep imageRegistry.ts under the file-size cap while adding the new provider entry. - Regenerated docs/reference/PROVIDER_REFERENCE.md (250 -> 251 providers) and updated the plain-text counts in README.md, AGENTS.md, CLAUDE.md. Tests (tests/unit/microsoft-designer-web-6672.test.ts, 16 cases): registry-entry shape assertions, the resolvePublicCred() shape assertion (Hard Rule #11), and the pure header/form-body/response- parsing helpers plus the handler's submit/poll/error/timeout paths against a mocked fetch — no live Designer session required. Reverse-engineered from the g4f MicrosoftDesigner.py provider reference (researched during #6672 triage); the exact upstream response shape has not been validated against a live Designer session, so the poll-loop implementation follows the documented g4f contract as closely as possible without a live capture. * fix(providers): satisfy web-cookie executor contract + document designer-web env vars (#6672) --- .env.example | 6 + .../6672-microsoftdesigner-image-provider.md | 1 + docs/reference/ENVIRONMENT.md | 2 + docs/reference/PROVIDER_REFERENCE.md | 8 +- open-sse/config/imageRegistry.ts | 11 + .../config/providers/registry/kie/models.ts | 43 +++ open-sse/executors/index.ts | 4 + open-sse/executors/microsoft-designer-web.ts | 51 ++++ open-sse/handlers/imageGeneration.ts | 12 + .../imageGeneration/providers/designerWeb.ts | 265 ++++++++++++++++++ open-sse/utils/publicCreds.ts | 8 + src/shared/constants/providers/web-cookie.ts | 13 + src/shared/providers/webSessionCredentials.ts | 7 + tests/unit/executor-web-cookie-sweep.test.ts | 4 +- .../unit/microsoft-designer-web-6672.test.ts | 216 ++++++++++++++ 15 files changed, 646 insertions(+), 5 deletions(-) create mode 100644 changelog.d/features/6672-microsoftdesigner-image-provider.md create mode 100644 open-sse/config/providers/registry/kie/models.ts create mode 100644 open-sse/executors/microsoft-designer-web.ts create mode 100644 open-sse/handlers/imageGeneration/providers/designerWeb.ts create mode 100644 tests/unit/microsoft-designer-web-6672.test.ts diff --git a/.env.example b/.env.example index 4a0198dc28..0ca0ae476f 100644 --- a/.env.example +++ b/.env.example @@ -1425,6 +1425,12 @@ APP_LOG_TO_FILE=true # NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s) # NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s) +# ── Microsoft Designer Web (Image Generation) ── +# Polling config for the microsoft-designer-web submit-then-poll image job. +# Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts +# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s) +# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s) + # ── AWS Bedrock (Kiro / Audio) ── # Region used to construct AWS Bedrock endpoints. Used by: # src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts. diff --git a/changelog.d/features/6672-microsoftdesigner-image-provider.md b/changelog.d/features/6672-microsoftdesigner-image-provider.md new file mode 100644 index 0000000000..3e425535f4 --- /dev/null +++ b/changelog.d/features/6672-microsoftdesigner-image-provider.md @@ -0,0 +1 @@ +- feat(sse): add Microsoft Designer as an unofficial web-session image provider, reverse-engineered submit-then-poll DallE.ashx flow (#6672) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f2f861d661..8832e68d6a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -801,6 +801,8 @@ Automatic model pricing data synchronization from external sources. | `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | | `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | | `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | +| `DESIGNER_WEB_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | Max wait for microsoft-designer-web image generation jobs. | +| `DESIGNER_WEB_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | microsoft-designer-web job polling frequency. | | `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | | `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | | `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 615b88d3ec..e18044d76d 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -10,7 +10,7 @@ lastUpdated: 2026-07-17 > Regenerate with: `npm run gen:provider-reference` > **Last generated:** 2026-07-17 -Total providers: **256**. See category breakdown below. +Total providers: **258**. See category breakdown below. ## Categories @@ -58,7 +58,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `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. | | `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | -## Web Cookie Providers (26) +## Web Cookie Providers (27) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -77,6 +77,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | | `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | | `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. | +| `microsoft-designer-web` | `msdesigner` | Microsoft Designer (Image Generation) | Web cookie | [link](https://designer.microsoft.com) | Sign in at designer.microsoft.com, then open DevTools → Network, generate an image, and find the request to DallE.ashx?action=GetDallEImagesCogSci. Copy the value of its Authorization: Bearer header (the access_token — no 'Bearer ' prefix). The token is short-lived; this is an unofficial, reverse-engineered integration. | | `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | | `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). Optionally append `; space_id=...` and/or `; notion_browser_id=...` if your workspace requires them. | | `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | @@ -89,7 +90,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zai-web` | `zw` | Z.ai Web (Free) | Web cookie | [link](https://chat.z.ai) | Paste the full Cookie header from chat.z.ai (must include the token= cookie) | | `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | -## API Key Providers (paid / paid-with-free-credits) (170) +## API Key Providers (paid / paid-with-free-credits) (171) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -145,6 +146,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | | `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | | `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | +| `freepik` | `fpk` | Freepik (Mystic) | API key, image | [link](https://freepik.com) | Get API key at freepik.com/developers (Mystic image endpoint) | | `freetheai` | `fta` | FreeTheAi | API key, aggregator | [link](https://freetheai.xyz) | Join the FreeTheAi Discord to get your free API key. | | `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | | `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 3aa06dc047..811b295ed3 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -170,6 +170,17 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, + "microsoft-designer-web": { + id: "microsoft-designer-web", + alias: "msdesigner", + baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx?action=GetDallEImagesCogSci", + authType: "apikey", + authHeader: "bearer", + format: "designer-web", + models: [{ id: "dall-e-3", name: "DALL-E 3 (Microsoft Designer Web)" }], + supportedSizes: ["1024x1024", "1792x1024", "1024x1792"], + }, + xai: { id: "xai", baseUrl: "https://api.x.ai/v1/images/generations", diff --git a/open-sse/config/providers/registry/kie/models.ts b/open-sse/config/providers/registry/kie/models.ts new file mode 100644 index 0000000000..78cf81658b --- /dev/null +++ b/open-sse/config/providers/registry/kie/models.ts @@ -0,0 +1,43 @@ +/** + * KIE image-provider model catalog — extracted from imageRegistry.ts + * (god-file decomposition, mirrors the lmarena/directModels.ts pattern). + * Pure data literal; imported by imageRegistry.ts. No behavior change. + */ +export const KIE_IMAGE_MODELS = [ + { id: "gpt4o-image", name: "KIE 4o Image" }, + { id: "seedream/4.5-text-to-image", name: "Seedream 4.5", isMarket: true }, + { id: "seedream/4.5-edit", name: "Seedream 4.5 Edit", isMarket: true }, + { id: "seedream/5.0-lite-text-to-image", name: "Seedream 5.0 Lite", isMarket: true }, + { id: "seedream/5.0-lite-image-to-image", name: "Seedream 5.0 Lite I2I", isMarket: true }, + { id: "z-image/4.0-text-to-image", name: "Z-Image v4.0", isMarket: true }, + { id: "z-image/4.5-text-to-image", name: "Z-Image v4.5", isMarket: true }, + { id: "google-imagen/imagen4-fast", name: "Imagen 4 Fast", isMarket: true }, + { id: "google-imagen/imagen4-ultra", name: "Imagen 4 Ultra", isMarket: true }, + { id: "google-imagen/imagen4", name: "Imagen 4", isMarket: true }, + { id: "google-imagen/nano-banana-2", name: "Nano Banana 2", isMarket: true }, + { id: "google-imagen/nano-banana", name: "Nano Banana", isMarket: true }, + { id: "google-imagen/nano-banana-pro", name: "Nano Banana Pro", isMarket: true }, + { id: "google-imagen/nano-banana-edit", name: "Nano Banana Edit", isMarket: true }, + { id: "flux/2-pro-image-to-image", name: "Flux 2 Pro I2I", isMarket: true }, + { id: "flux/2-pro-text-to-image", name: "Flux 2 Pro T2I", isMarket: true }, + { id: "flux/2-image-to-image", name: "Flux 2 I2I", isMarket: true }, + { id: "flux/2-text-to-image", name: "Flux 2 T2I", isMarket: true }, + { id: "flux/kontext", name: "Flux Kontext", isMarket: true }, + { id: "grok-imagine/text-to-image", name: "Grok Imagine T2I", isMarket: true }, + { id: "grok-imagine/image-to-image", name: "Grok Imagine I2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-text-to-image", name: "GPT Image 1.5 T2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-image-to-image", name: "GPT Image 1.5 I2I", isMarket: true }, + { id: "gpt/gpt-image-2-text-to-image", name: "GPT Image 2 T2I", isMarket: true }, + { id: "gpt/gpt-image-2-image-to-image", name: "GPT Image 2 I2I", isMarket: true }, + { id: "ideogram/v3-text-to-image", name: "Ideogram v3", isMarket: true }, + { id: "ideogram/v3-edit", name: "Ideogram v3 Edit", isMarket: true }, + { id: "ideogram/v3-remix", name: "Ideogram v3 Remix", isMarket: true }, + { id: "ideogram/v3-reframe", name: "Ideogram v3 Reframe", isMarket: true }, + { id: "qwen/text-to-image", name: "Qwen T2I", isMarket: true }, + { id: "qwen/image-to-image", name: "Qwen I2I", isMarket: true }, + { id: "qwen/image-edit", name: "Qwen Edit", isMarket: true }, + { id: "qwen2/image-edit", name: "Qwen2 Edit", isMarket: true }, + { id: "qwen2/text-to-image", name: "Qwen2 T2I", isMarket: true }, + { id: "wan/2.7-image", name: "Wan 2.7 Image", isMarket: true }, + { id: "wan/2.7-image-pro", name: "Wan 2.7 Image Pro", isMarket: true }, +]; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 9b58a644fd..e9f2d76a17 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -36,6 +36,7 @@ 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 { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts"; import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; import { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; import { FeloWebExecutor } from "./felo-web.ts"; @@ -125,6 +126,8 @@ const executors = { "copilot-web": new CopilotWebExecutor(), "copilot-m365-web": new CopilotM365WebExecutor(), copilot: new CopilotWebExecutor(), // Alias + "microsoft-designer-web": new MicrosoftDesignerWebExecutor(), + msdesigner: new MicrosoftDesignerWebExecutor(), // Alias "veoaifree-web": new VeoAIFreeWebExecutor(), "veo-free": new VeoAIFreeWebExecutor(), // Alias "duckduckgo-web": new DuckDuckGoWebExecutor(), @@ -239,6 +242,7 @@ export { DevinCliExecutor } from "./devin-cli.ts"; export { AuggieExecutor } from "./auggie.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; export { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; +export { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts"; export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; export { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts"; export { FeloWebExecutor } from "./felo-web.ts"; diff --git a/open-sse/executors/microsoft-designer-web.ts b/open-sse/executors/microsoft-designer-web.ts new file mode 100644 index 0000000000..d2ffab604d --- /dev/null +++ b/open-sse/executors/microsoft-designer-web.ts @@ -0,0 +1,51 @@ +// MicrosoftDesignerWebExecutor — chat-completions guard for the +// microsoft-designer-web web-cookie provider (#6672). +// +// Microsoft Designer (designerapp.officeapps.live.com/DallE.ashx) is an +// image-generation-only upstream: it has no chat/completions surface at all. +// The real request/response flow lives entirely in the image-generation +// handler (open-sse/handlers/imageGeneration/providers/designerWeb.ts), +// dispatched from open-sse/handlers/imageGeneration.ts by +// providerConfig.format === "designer-web" — NOT through getExecutor(). +// +// microsoft-designer-web is still listed in WEB_COOKIE_PROVIDERS (it uses +// the same unofficial, DevTools-sourced bearer-token credential UX and +// subscription-risk notice as the other web-cookie providers — see +// tests/unit/microsoft-designer-web-6672.test.ts). Without a registered +// executor here, getExecutor("microsoft-designer-web") silently falls +// through to DefaultExecutor's `PROVIDERS[provider] || PROVIDERS.openai` +// fallback (open-sse/executors/index.ts:176 comment, #6699) — which would +// send the user's real Designer bearer token to api.openai.com, mislabeled +// as an OpenAI request, if anything ever mis-routes a chat/completions call +// to this provider. +// +// This executor closes that gap cheaply: it never calls the network. Any +// chat/completions attempt against microsoft-designer-web is rejected +// immediately with a clean, sanitized 400 telling the caller to use +// /v1/images/generations instead — satisfying the executor wrapper +// contract (tests/unit/executor-web-cookie-sweep.test.ts) without ever +// forwarding credentials anywhere. +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult } from "../utils/error.ts"; + +const DESIGNER_WEB_BASE_URL = + "https://designerapp.officeapps.live.com/designerapp/DallE.ashx?action=GetDallEImagesCogSci"; + +export class MicrosoftDesignerWebExecutor extends BaseExecutor { + constructor() { + super("microsoft-designer-web", { id: "microsoft-designer-web", baseUrl: DESIGNER_WEB_BASE_URL }); + } + + async execute(_input: ExecuteInput) { + return makeExecutorErrorResult( + 400, + "microsoft-designer-web is an image-generation-only provider and does not support " + + "chat completions. Use POST /v1/images/generations with model " + + '"microsoft-designer-web/dall-e-3" instead.', + _input.body, + DESIGNER_WEB_BASE_URL + ); + } +} + +export default MicrosoftDesignerWebExecutor; diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index b8474a425a..f59aff411f 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -64,6 +64,7 @@ import { CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; +import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; @@ -462,6 +463,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "designer-web") { + return handleDesignerWebImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "nanobanana") { return handleNanoBananaImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/designerWeb.ts b/open-sse/handlers/imageGeneration/providers/designerWeb.ts new file mode 100644 index 0000000000..f32d478d36 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/designerWeb.ts @@ -0,0 +1,265 @@ +// Microsoft Designer (unofficial, reverse-engineered web API) image handler. +// Family: designer-web | Provider: microsoft-designer-web +// Reference: g4f/Provider/needs_auth/MicrosoftDesigner.py (fetched + verified +// during triage of #6672) — Bearer access_token auth against +// designerapp.officeapps.live.com/designerapp/DallE.ashx, submit-then-poll +// for image_urls_thumbnail[].ImageUrl. +// +// The upstream ClientId header is a fixed, publicly-shared value the +// designer.microsoft.com frontend sends on every session (not a secret) — +// routed through resolvePublicCred() per Hard Rule #11 / docs/security/PUBLIC_CREDS.md. + +import { randomUUID, randomBytes } from "node:crypto"; +import { resolvePublicCred } from "../../../utils/publicCreds.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +const DESIGNER_WEB_POLL_TIMEOUT_MS_DEFAULT = 60000; +const DESIGNER_WEB_POLL_INTERVAL_MS_DEFAULT = 2000; +const DESIGNER_WEB_BATCH_SIZE = "4"; + +/** Maps an OpenAI-style "WxH" size string to the closest Designer aspect ratio bucket. */ +export function mapDesignerWebImageSize(size: unknown): "1_1" | "16_9" | "9_16" { + if (typeof size !== "string" || !size.includes("x")) return "1_1"; + const [wRaw, hRaw] = size.split("x"); + const w = Number(wRaw); + const h = Number(hRaw); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return "1_1"; + if (w > h * 1.2) return "16_9"; + if (h > w * 1.2) return "9_16"; + return "1_1"; +} + +/** Builds the fixed + per-request headers Microsoft Designer expects on every call. */ +export function buildDesignerWebHeaders({ + accessToken, + sessionId = randomUUID(), + userId = randomBytes(16).toString("hex"), +}: { + accessToken: string; + sessionId?: string; + userId?: string; +}): Record { + return { + Authorization: `Bearer ${accessToken}`, + ClientId: resolvePublicCred("microsoft_designer_client_id"), + SessionId: sessionId, + UserId: userId, + "Content-Type": "application/x-www-form-urlencoded", + }; +} + +/** Builds the DallE.ashx form body from an OpenAI-shaped image-generation request. */ +export function buildDesignerWebFormBody(prompt: string, size: unknown): URLSearchParams { + const params = new URLSearchParams(); + params.set("dalle-caption", prompt); + params.set("dalle-image-size", mapDesignerWebImageSize(size)); + params.set("dalle-batch-size", DESIGNER_WEB_BATCH_SIZE); + params.set("dalle-seed", String(Math.floor(Math.random() * 1_000_000_000))); + return params; +} + +interface DesignerWebParsedResponse { + status: "ready" | "pending" | "empty"; + imageUrls: string[]; + pollIntervalMs: number | null; +} + +/** Parses a DallE.ashx JSON body into a ready/pending/empty verdict. */ +export function parseDesignerWebResponse(json: unknown): DesignerWebParsedResponse { + const body = (json ?? {}) as Record; + const thumbs = Array.isArray(body.image_urls_thumbnail) ? body.image_urls_thumbnail : []; + const imageUrls = thumbs + .map((t) => (t && typeof t === "object" ? (t as Record).ImageUrl : null)) + .filter((u): u is string => typeof u === "string" && u.length > 0); + + if (imageUrls.length > 0) { + return { status: "ready", imageUrls, pollIntervalMs: null }; + } + + const pollingMeta = (body.polling_response as Record | undefined) + ?.polling_meta_data as Record | undefined; + const pollIntervalMs = Number.isFinite(pollingMeta?.poll_interval) + ? Number(pollingMeta?.poll_interval) + : null; + + if (pollIntervalMs !== null) { + return { status: "pending", imageUrls: [], pollIntervalMs }; + } + + return { status: "empty", imageUrls: [], pollIntervalMs: null }; +} + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +interface DesignerWebRequestConfig { + prompt: string; + accessToken: string; + headers: Record; + formBody: URLSearchParams; + timeoutMs: number; + pollIntervalMs: number; +} + +/** Validates the request and resolves auth + poll timing. Returns an error status/message on failure. */ +function resolveDesignerWebRequest( + body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown }, + credentials: { apiKey?: string; accessToken?: string } +): { ok: true; config: DesignerWebRequestConfig } | { ok: false; status: number; error: string } { + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return { + ok: false, + status: 400, + error: "Prompt is required for Microsoft Designer image generation", + }; + } + + const accessToken = credentials?.apiKey || credentials?.accessToken; + if (!accessToken) { + return { ok: false, status: 401, error: "Microsoft Designer credentials missing access_token" }; + } + + const timeoutMs = normalizePositiveNumber( + body.timeout_ms, + normalizePositiveNumber( + process.env.DESIGNER_WEB_POLL_TIMEOUT_MS, + DESIGNER_WEB_POLL_TIMEOUT_MS_DEFAULT + ) + ); + const pollIntervalMs = normalizePositiveNumber( + body.poll_interval_ms, + normalizePositiveNumber( + process.env.DESIGNER_WEB_POLL_INTERVAL_MS, + DESIGNER_WEB_POLL_INTERVAL_MS_DEFAULT + ) + ); + + return { + ok: true, + config: { + prompt, + accessToken, + headers: buildDesignerWebHeaders({ accessToken }), + formBody: buildDesignerWebFormBody(prompt, body.size), + timeoutMs, + pollIntervalMs, + }, + }; +} + +type DesignerWebStepResult = + | { done: false; waitMs: number } + | { done: true; success: true; imageUrls: string[] } + | { done: true; success: false; status: number; error: string }; + +/** Runs one submit/poll fetch cycle and classifies the outcome. */ +async function stepDesignerWebPoll( + baseUrl: string, + headers: Record, + formBody: URLSearchParams, + pollIntervalMs: number, + fetchImpl: typeof fetch +): Promise { + const resp = await fetchImpl(baseUrl, { method: "POST", headers, body: formBody }); + + if (!resp.ok) { + return { done: true, success: false, status: resp.status, error: sanitizeErrorMessage(await resp.text()) }; + } + + const parsed = parseDesignerWebResponse(await resp.json()); + + if (parsed.status === "ready") { + return { done: true, success: true, imageUrls: parsed.imageUrls }; + } + if (parsed.status === "empty") { + return { + done: true, + success: false, + status: 502, + error: "Microsoft Designer response did not contain image data or polling metadata", + }; + } + return { done: false, waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs) }; +} + +/** Drives the submit-then-poll loop to completion, timeout, or a terminal error. */ +async function runDesignerWebPollLoop( + baseUrl: string, + config: DesignerWebRequestConfig, + fetchImpl: typeof fetch, + log?: { info?: (...args: unknown[]) => void } +): Promise { + const deadline = Date.now() + config.timeoutMs; + let attempt = 0; + + while (Date.now() < deadline) { + attempt += 1; + const step = await stepDesignerWebPoll( + baseUrl, + config.headers, + config.formBody, + config.pollIntervalMs, + fetchImpl + ); + if (step.done) return step; + log?.info?.("IMAGE", `designer-web pending, poll #${attempt} in ${step.waitMs}ms`); + await new Promise((resolve) => setTimeout(resolve, step.waitMs)); + } + + return { + done: true, + success: false, + status: 504, + error: "Microsoft Designer image generation timed out waiting for a result", + }; +} + +export async function handleDesignerWebImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + fetchImpl = fetch, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + const resolved = resolveDesignerWebRequest(body, credentials); + if (!resolved.ok) { + return saveImageErrorResult({ provider, model, status: resolved.status, startTime, error: resolved.error }); + } + + try { + const outcome = await runDesignerWebPollLoop(providerConfig.baseUrl, resolved.config, fetchImpl, log); + if (outcome.success) { + return saveImageSuccessResult({ + provider, + model, + startTime, + images: outcome.imageUrls.map((url) => ({ url })), + }); + } + if (log?.error) { + log.error("IMAGE", `${provider} designer-web error ${outcome.status}: ${outcome.error}`); + } + return saveImageErrorResult({ provider, model, status: outcome.status, startTime, error: outcome.error }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + if (log?.error) { + log.error("IMAGE", `${provider} designer-web exception: ${errorText}`); + } + return saveImageErrorResult({ provider, model, status: 500, startTime, error: errorText }); + } +} diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index c83fee9d20..799901ca48 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -184,6 +184,14 @@ const EMBEDDED_DEFAULTS = { ], // Trae Cloud IDE — public oauth client id trae_id: [10, 3, 95, 6, 10, 22, 66, 3, 11, 90, 72, 31, 91, 2], + // Microsoft Designer web app — public ClientId header sent by the + // designer.microsoft.com frontend to designerapp.officeapps.live.com + // (not a secret — every browser session sends the same fixed value; + // reverse-engineered from the g4f MicrosoftDesigner provider reference). + microsoft_designer_client_id: [ + 13, 88, 13, 91, 68, 89, 65, 21, 72, 26, 21, 76, 0, 65, 93, 2, 26, 23, 28, 87, 14, 87, 8, 95, 12, + 17, 70, 6, 24, 66, 17, 1, 10, 95, 81, 28, + ], // Microsoft Edge Read Aloud (EdgeTTS) — public "trusted client token" used to // derive the Sec-MS-GEC anti-abuse header. Hardcoded in every known Edge // browser build and every open-source edge-tts reimplementation (e.g. diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index e47c4d9c8d..e17c288ada 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -129,6 +129,19 @@ export const WEB_COOKIE_PROVIDERS = { subscriptionRisk: true, riskNoticeVariant: "webCookie", }, + "microsoft-designer-web": { + id: "microsoft-designer-web", + alias: "msdesigner", + name: "Microsoft Designer (Image Generation)", + icon: "auto_awesome", + color: "#0078D4", + textIcon: "MSD", + website: "https://designer.microsoft.com", + authHint: + "Sign in at designer.microsoft.com, then open DevTools → Network, generate an image, and find the request to DallE.ashx?action=GetDallEImagesCogSci. Copy the value of its Authorization: Bearer header (the access_token — no 'Bearer ' prefix). The token is short-lived; this is an unofficial, reverse-engineered integration.", + 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 e3e7c96588..0d870dbec5 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -109,6 +109,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: false, storageKeys: ["token", "access_token", "accessToken"], }, + "microsoft-designer-web": { + kind: "token", + credentialName: "access_token", + placeholder: "access_token=... (Authorization: Bearer header from the DallE.ashx request)", + acceptsFullCookieHeader: false, + storageKeys: ["token", "access_token", "accessToken"], + }, "copilot-m365-web": { kind: "token", credentialName: "access_token + chathubPath", diff --git a/tests/unit/executor-web-cookie-sweep.test.ts b/tests/unit/executor-web-cookie-sweep.test.ts index 8aa3ca0245..9392247ab8 100644 --- a/tests/unit/executor-web-cookie-sweep.test.ts +++ b/tests/unit/executor-web-cookie-sweep.test.ts @@ -17,7 +17,7 @@ * The duckduckgo-web executor was the first known case. To prevent any * future executor from regressing on the same contract, this sweep test * imports every executor in `WEB_COOKIE_PROVIDERS` + `NOAUTH_PROVIDERS` - * (20 web-cookie + 2 noauth = 22 total), calls `execute()` with a minimal + * (26 web-cookie + 2 noauth = 28 total), calls `execute()` with a minimal * but valid input, and asserts the wrapper shape. Tests use the * pre-aborted signal path or empty-creds path so no real upstream call * is needed. @@ -116,7 +116,7 @@ function assertExecutorWrapperShape( } describe("web-cookie + noauth executor wrapper contract sweep", () => { - describe("WEB_COOKIE_PROVIDERS (20)", () => { + describe("WEB_COOKIE_PROVIDERS (26)", () => { for (const providerId of WEB_COOKIE_IDS) { it(`${providerId} executor returns wrapper shape`, async () => { const executor = getExecutor(providerId); diff --git a/tests/unit/microsoft-designer-web-6672.test.ts b/tests/unit/microsoft-designer-web-6672.test.ts new file mode 100644 index 0000000000..8cb139b64d --- /dev/null +++ b/tests/unit/microsoft-designer-web-6672.test.ts @@ -0,0 +1,216 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; +import { + buildDesignerWebHeaders, + buildDesignerWebFormBody, + mapDesignerWebImageSize, + parseDesignerWebResponse, + handleDesignerWebImageGeneration, +} from "../../open-sse/handlers/imageGeneration/providers/designerWeb.ts"; +import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts"; +import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +// --- Registry entries ------------------------------------------------- + +test("microsoft-designer-web is registered in WEB_COOKIE_PROVIDERS with a webCookie risk notice", () => { + const entry = (WEB_COOKIE_PROVIDERS as Record)["microsoft-designer-web"]; + assert.ok(entry, "microsoft-designer-web must exist in WEB_COOKIE_PROVIDERS"); + assert.equal(entry.id, "microsoft-designer-web"); + assert.equal(entry.subscriptionRisk, true); + assert.equal(entry.riskNoticeVariant, "webCookie"); + assert.match(entry.website, /designer\.microsoft\.com/); +}); + +test("microsoft-designer-web is registered in IMAGE_PROVIDERS with the designer-web format", () => { + const entry = (IMAGE_PROVIDERS as Record)["microsoft-designer-web"]; + assert.ok(entry, "microsoft-designer-web must exist in IMAGE_PROVIDERS"); + assert.equal(entry.format, "designer-web"); + assert.match(entry.baseUrl, /designerapp\.officeapps\.live\.com/); + assert.ok(Array.isArray(entry.models) && entry.models.length > 0); +}); + +// --- Public credential (Hard Rule #11) --------------------------------- + +test("microsoft_designer_client_id embedded default decodes to the public Designer ClientId", () => { + assert.equal(resolvePublicCred("microsoft_designer_client_id"), "b5c2664a-7e9b-4a7a-8c9a-cd2c52dcf621"); +}); + +test("designerWeb.ts never embeds the raw ClientId literal (Hard Rule #11)", () => { + const src = fs.readFileSync( + path.join(repoRoot, "open-sse/handlers/imageGeneration/providers/designerWeb.ts"), + "utf8" + ) as string; + assert.ok( + !src.includes("b5c2664a-7e9b-4a7a-8c9a-cd2c52dcf621"), + "designerWeb.ts must resolve the ClientId via resolvePublicCred(), not a string literal" + ); + assert.ok( + src.includes('resolvePublicCred("microsoft_designer_client_id")'), + "designerWeb.ts must call resolvePublicCred for the Designer ClientId" + ); +}); + +// --- Pure helpers -------------------------------------------------------- + +test("mapDesignerWebImageSize buckets sizes into square/landscape/portrait", () => { + assert.equal(mapDesignerWebImageSize("1024x1024"), "1_1"); + assert.equal(mapDesignerWebImageSize("1792x1024"), "16_9"); + assert.equal(mapDesignerWebImageSize("1024x1792"), "9_16"); + assert.equal(mapDesignerWebImageSize(undefined), "1_1"); + assert.equal(mapDesignerWebImageSize("garbage"), "1_1"); +}); + +test("buildDesignerWebHeaders sets Bearer auth + the public ClientId + per-request SessionId/UserId", () => { + const headers = buildDesignerWebHeaders({ accessToken: "tok-123" }); + assert.equal(headers.Authorization, "Bearer tok-123"); + assert.equal(headers.ClientId, "b5c2664a-7e9b-4a7a-8c9a-cd2c52dcf621"); + assert.ok(headers.SessionId && headers.SessionId.length > 0); + assert.ok(headers.UserId && headers.UserId.length > 0); + assert.equal(headers["Content-Type"], "application/x-www-form-urlencoded"); +}); + +test("buildDesignerWebFormBody encodes prompt, mapped size, fixed batch size, and a seed", () => { + const form = buildDesignerWebFormBody("a cat astronaut", "1792x1024"); + assert.equal(form.get("dalle-caption"), "a cat astronaut"); + assert.equal(form.get("dalle-image-size"), "16_9"); + assert.equal(form.get("dalle-batch-size"), "4"); + assert.ok(Number(form.get("dalle-seed")) >= 0); +}); + +test("parseDesignerWebResponse: ready state extracts thumbnail image URLs", () => { + const parsed = parseDesignerWebResponse({ + image_urls_thumbnail: [{ ImageUrl: "https://example.com/a.png" }, { ImageUrl: "https://example.com/b.png" }], + }); + assert.equal(parsed.status, "ready"); + assert.deepEqual(parsed.imageUrls, ["https://example.com/a.png", "https://example.com/b.png"]); +}); + +test("parseDesignerWebResponse: pending state surfaces the polling interval", () => { + const parsed = parseDesignerWebResponse({ + polling_response: { polling_meta_data: { poll_interval: 1500 } }, + }); + assert.equal(parsed.status, "pending"); + assert.equal(parsed.pollIntervalMs, 1500); + assert.deepEqual(parsed.imageUrls, []); +}); + +test("parseDesignerWebResponse: unrecognized shape is 'empty'", () => { + const parsed = parseDesignerWebResponse({ unexpected: true }); + assert.equal(parsed.status, "empty"); +}); + +// --- Handler (mocked fetch — no live Designer session required) --------- + +function jsonResponse(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as Response; +} + +test("handleDesignerWebImageGeneration returns 400 when prompt is missing", async () => { + const result = await handleDesignerWebImageGeneration({ + model: "dall-e-3", + provider: "microsoft-designer-web", + providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" }, + body: {}, + credentials: { apiKey: "tok" }, + fetchImpl: async () => jsonResponse(200, {}), + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); +}); + +test("handleDesignerWebImageGeneration returns 401 when access_token is missing", async () => { + const result = await handleDesignerWebImageGeneration({ + model: "dall-e-3", + provider: "microsoft-designer-web", + providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" }, + body: { prompt: "a cat" }, + credentials: {}, + fetchImpl: async () => jsonResponse(200, {}), + }); + assert.equal(result.success, false); + assert.equal(result.status, 401); +}); + +test("handleDesignerWebImageGeneration succeeds immediately when the first response is already ready", async () => { + let calls = 0; + const result = await handleDesignerWebImageGeneration({ + model: "dall-e-3", + provider: "microsoft-designer-web", + providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" }, + body: { prompt: "a cat astronaut", size: "1024x1024" }, + credentials: { apiKey: "tok-abc" }, + fetchImpl: async () => { + calls += 1; + return jsonResponse(200, { + image_urls_thumbnail: [{ ImageUrl: "https://example.com/ready.png" }], + }); + }, + }); + assert.equal(calls, 1); + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "https://example.com/ready.png"); +}); + +test("handleDesignerWebImageGeneration polls until ready, bounded by poll_interval_ms/timeout_ms", async () => { + let calls = 0; + const result = await handleDesignerWebImageGeneration({ + model: "dall-e-3", + provider: "microsoft-designer-web", + providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" }, + body: { prompt: "a cat astronaut", timeout_ms: 5000, poll_interval_ms: 1 }, + credentials: { apiKey: "tok-abc" }, + fetchImpl: async () => { + calls += 1; + if (calls < 3) { + return jsonResponse(200, { + polling_response: { polling_meta_data: { poll_interval: 1 } }, + }); + } + return jsonResponse(200, { + image_urls_thumbnail: [{ ImageUrl: "https://example.com/final.png" }], + }); + }, + }); + assert.equal(calls, 3); + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "https://example.com/final.png"); +}); + +test("handleDesignerWebImageGeneration surfaces a sanitized error on a non-OK upstream response", async () => { + const result = await handleDesignerWebImageGeneration({ + model: "dall-e-3", + provider: "microsoft-designer-web", + providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" }, + body: { prompt: "a cat astronaut" }, + credentials: { apiKey: "expired-token" }, + fetchImpl: async () => jsonResponse(401, { error: "invalid_token" }), + }); + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.ok(!String(result.error).includes(" at "), "error must not leak a stack trace"); +}); + +test("handleDesignerWebImageGeneration times out cleanly when the upstream never becomes ready", async () => { + const result = await handleDesignerWebImageGeneration({ + model: "dall-e-3", + provider: "microsoft-designer-web", + providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" }, + body: { prompt: "a cat astronaut", timeout_ms: 5, poll_interval_ms: 1 }, + credentials: { apiKey: "tok-abc" }, + fetchImpl: async () => + jsonResponse(200, { polling_response: { polling_meta_data: { poll_interval: 1 } } }), + }); + assert.equal(result.success, false); + assert.equal(result.status, 504); +});