diff --git a/docs/frameworks/OPEN_SSE_ARCHITECTURE.md b/docs/frameworks/OPEN_SSE_ARCHITECTURE.md index 83ebf35a95..0f265d53bd 100644 --- a/docs/frameworks/OPEN_SSE_ARCHITECTURE.md +++ b/docs/frameworks/OPEN_SSE_ARCHITECTURE.md @@ -368,7 +368,7 @@ const result = await executor.execute({ }); ```` -The factory is generated from `config/providerRegistry.ts` which lists all 338 providers and their executor class. +Resolution goes through the `ExecutorRegistry` (`executors/registry.ts`): every specialized executor is declared in the built-in table of `executors/index.ts` and registered via `registerExecutor(alias, instance)` at module load; `getExecutor()` consults the registry and falls back to a memoized `DefaultExecutor` for any provider without a specialized entry. The full alias → executor mapping is characterized by the golden test `tests/unit/executor-map-golden.test.ts`. --- diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index a780e1c8b5..3fc2bdf9b3 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -1,4 +1,9 @@ import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts"; +import { + registerExecutor, + getRegisteredExecutor, + hasRegisteredExecutor, +} from "./registry.ts"; import { AntigravityExecutor } from "./antigravity.ts"; import { GithubExecutor } from "./github.ts"; import { GheCopilotExecutor } from "./ghe-copilot.ts"; @@ -78,6 +83,12 @@ import { XaiExecutor } from "./xai.ts"; import { PromptQlExecutor } from "./promptql.ts"; import { ConolWebExecutor } from "./conol-web.ts"; +// R0.3 — declarative built-in table. The object literal stays as the single +// place built-ins are declared (compile-time duplicate-key safety; the +// check:known-symbols gate parses this literal from source), but lookup goes +// through the ExecutorRegistry (./registry.ts): every entry is registered at +// module load below, and getExecutor()/hasSpecializedExecutor() consult the +// registry — the literal is never read at request time. const executors = { antigravity: new AntigravityExecutor(), agy: new AntigravityExecutor(), @@ -221,6 +232,13 @@ const executors = { cnl: new ConolWebExecutor(), // Alias }; +// Bootstrap: register every built-in in the ExecutorRegistry. registerExecutor +// throws on duplicates, so an alias collision fails at module load, exactly as +// loudly as a duplicate object key would have failed at lint time. +for (const [alias, executor] of Object.entries(executors)) { + registerExecutor(alias, executor); +} + const defaultCache = new Map(); // #6699 — providers that exist ONLY as Cloud Agent task-API entries @@ -246,7 +264,8 @@ const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]); const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS)); export function getExecutor(provider) { - if (executors[provider]) return executors[provider]; + const registered = getRegisteredExecutor(provider); + if (registered) return registered; if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) { const err = new Error( `Provider "${provider}" is a cloud-agent provider and does not support direct chat completions; use the Cloud Agents task API instead.` @@ -266,9 +285,11 @@ export function getExecutor(provider) { } export function hasSpecializedExecutor(provider) { - return !!executors[provider]; + return hasRegisteredExecutor(provider); } +export { registerExecutor, listExecutorAliases } from "./registry.ts"; + export { BaseExecutor } from "./base.ts"; export { AntigravityExecutor } from "./antigravity.ts"; export { GithubExecutor } from "./github.ts"; diff --git a/open-sse/executors/registry.ts b/open-sse/executors/registry.ts new file mode 100644 index 0000000000..4d6a002ecd --- /dev/null +++ b/open-sse/executors/registry.ts @@ -0,0 +1,38 @@ +import type { BaseExecutor } from "./base.ts"; + +// R0.3 — ExecutorRegistry: runtime registry for provider executors, mirroring +// open-sse/translator/registry.ts. Built-ins register at module load from +// executors/index.ts; getExecutor() resolves through this map instead of a +// hard-coded object literal. This is the seam the v4 plan (M1.6 +// host.registerProvider) extends — today the surface is internal-only. +// +// The alias → executor mapping is characterized by +// tests/unit/executor-map-golden.test.ts (tests/snapshots/executors/): any +// change to keys, classes or instance sharing shows up as a golden diff. + +const registry = new Map(); + +/** + * Register an executor under an alias. Aliases are unique: registering the + * same alias twice throws, preserving the guarantee the old object literal + * gave at compile time (duplicate keys were impossible). + */ +export function registerExecutor(alias: string, executor: BaseExecutor): void { + if (registry.has(alias)) { + throw new Error(`executor alias already registered: "${alias}"`); + } + registry.set(alias, executor); +} + +export function getRegisteredExecutor(alias: string): BaseExecutor | undefined { + return registry.get(alias); +} + +export function hasRegisteredExecutor(alias: string): boolean { + return registry.has(alias); +} + +/** All registered aliases, in registration order. */ +export function listExecutorAliases(): string[] { + return [...registry.keys()]; +} diff --git a/tests/snapshots/executors/dispatch-rules.json b/tests/snapshots/executors/dispatch-rules.json new file mode 100644 index 0000000000..b9ce949a99 --- /dev/null +++ b/tests/snapshots/executors/dispatch-rules.json @@ -0,0 +1,91 @@ +{ + "cloudAgentGuard": { + "jules": { + "message": "Provider \"jules\" is a cloud-agent provider and does not support direct chat completions; use the Cloud Agents task API instead.", + "status": 400, + "throws": true + } + }, + "fallback": { + "className": "DefaultExecutor", + "configSource": "openai", + "provider": "golden-test-unknown-provider" + }, + "searchGuard": { + "brave-search": { + "message": "Provider \"brave-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "duckduckgo-free": { + "message": "Provider \"duckduckgo-free\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "exa-search": { + "message": "Provider \"exa-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "firecrawl": { + "message": "Provider \"firecrawl\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "google-pse-search": { + "message": "Provider \"google-pse-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "jina-search": { + "message": "Provider \"jina-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "linkup-search": { + "message": "Provider \"linkup-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "ollama-search": { + "message": "Provider \"ollama-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "perplexity-search": { + "message": "Provider \"perplexity-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "searchapi-search": { + "message": "Provider \"searchapi-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "searxng-search": { + "message": "Provider \"searxng-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "serper-search": { + "message": "Provider \"serper-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "tavily-search": { + "message": "Provider \"tavily-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "youcom-search": { + "message": "Provider \"youcom-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + }, + "zai-search": { + "message": "Provider \"zai-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.", + "status": 400, + "throws": true + } + } +} diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json new file mode 100644 index 0000000000..d0ce4024b5 --- /dev/null +++ b/tests/snapshots/executors/executor-map.json @@ -0,0 +1,691 @@ +{ + "entries": { + "9router": { + "className": "NineRouterExecutor", + "configSource": "", + "provider": "9router" + }, + "adapta-web": { + "className": "AdaptaWebExecutor", + "configSource": "", + "provider": "adapta-web" + }, + "adobe-firefly": { + "className": "AdobeFireflyExecutor", + "configSource": "", + "provider": "adobe-firefly" + }, + "adp-web": { + "className": "AdaptaWebExecutor", + "configSource": "", + "provider": "adapta-web" + }, + "agy": { + "className": "AntigravityExecutor", + "configSource": "antigravity", + "provider": "antigravity" + }, + "amazon-q": { + "className": "KiroExecutor", + "configSource": "kiro", + "provider": "amazon-q" + }, + "antigravity": { + "className": "AntigravityExecutor", + "configSource": "antigravity", + "provider": "antigravity" + }, + "auggie": { + "className": "AuggieExecutor", + "configSource": "", + "provider": "auggie" + }, + "azure-ai": { + "className": "AzureAiExecutor", + "configSource": "openai", + "provider": "azure-ai" + }, + "azure-openai": { + "className": "AzureOpenAIExecutor", + "configSource": "openai", + "provider": "azure-openai" + }, + "bb-web": { + "className": "BlackboxWebExecutor", + "configSource": "", + "provider": "blackbox-web" + }, + "bedrock": { + "className": "BedrockExecutor", + "configSource": "bedrock", + "provider": "bedrock" + }, + "blackbox-web": { + "className": "BlackboxWebExecutor", + "configSource": "", + "provider": "blackbox-web" + }, + "cbcn": { + "className": "CodeBuddyCnExecutor", + "configSource": "codebuddy-cn", + "provider": "codebuddy-cn" + }, + "cf": { + "className": "CloudflareAIExecutor", + "configSource": "cloudflare-ai", + "provider": "cloudflare-ai" + }, + "cfp": { + "className": "CloudflarePlaygroundExecutor", + "configSource": "", + "provider": "cloudflare-playground" + }, + "cgpt-codex": { + "className": "ChatGptWebCodexExecutor", + "configSource": "", + "provider": "chatgpt-web-codex" + }, + "cgpt-web": { + "className": "ChatGptWebExecutor", + "configSource": "", + "provider": "chatgpt-web" + }, + "chatgpt-web": { + "className": "ChatGptWebExecutor", + "configSource": "", + "provider": "chatgpt-web" + }, + "chatgpt-web-codex": { + "className": "ChatGptWebCodexExecutor", + "configSource": "", + "provider": "chatgpt-web-codex" + }, + "cheaperinference": { + "className": "CheaperInferenceExecutor", + "configSource": "cheaperinference", + "provider": "cheaperinference" + }, + "chipotle": { + "className": "ChipotleExecutor", + "configSource": "", + "provider": "chipotle" + }, + "cinf": { + "className": "CheaperInferenceExecutor", + "configSource": "cheaperinference", + "provider": "cheaperinference" + }, + "claude-web": { + "className": "ClaudeWebExecutor", + "configSource": "", + "provider": "claude-web" + }, + "cliproxyapi": { + "className": "CliproxyapiExecutor", + "configSource": "", + "provider": "cliproxyapi" + }, + "cloudflare-ai": { + "className": "CloudflareAIExecutor", + "configSource": "cloudflare-ai", + "provider": "cloudflare-ai" + }, + "cloudflare-playground": { + "className": "CloudflarePlaygroundExecutor", + "configSource": "", + "provider": "cloudflare-playground" + }, + "cmd": { + "className": "CommandCodeExecutor", + "configSource": "", + "provider": "command-code" + }, + "cnl": { + "className": "ConolWebExecutor", + "configSource": "", + "provider": "conol-web" + }, + "codebuddy-cn": { + "className": "CodeBuddyCnExecutor", + "configSource": "codebuddy-cn", + "provider": "codebuddy-cn" + }, + "codex": { + "className": "CodexExecutor", + "configSource": "codex", + "provider": "codex" + }, + "command-code": { + "className": "CommandCodeExecutor", + "configSource": "", + "provider": "command-code" + }, + "conol-web": { + "className": "ConolWebExecutor", + "configSource": "", + "provider": "conol-web" + }, + "copilot": { + "className": "CopilotWebExecutor", + "configSource": "", + "provider": "copilot-web" + }, + "copilot-m365-web": { + "className": "CopilotM365WebExecutor", + "configSource": "", + "provider": "copilot-m365-web" + }, + "copilot-web": { + "className": "CopilotWebExecutor", + "configSource": "", + "provider": "copilot-web" + }, + "cpa": { + "className": "CliproxyapiExecutor", + "configSource": "", + "provider": "cliproxyapi" + }, + "cu": { + "className": "CursorExecutor", + "configSource": "cursor", + "provider": "cursor" + }, + "cursor": { + "className": "CursorExecutor", + "configSource": "cursor", + "provider": "cursor" + }, + "cw-web": { + "className": "ClaudeWebExecutor", + "configSource": "", + "provider": "claude-web" + }, + "dario": { + "className": "DarioExecutor", + "configSource": "", + "provider": "dario" + }, + "db": { + "className": "DoubaoWebExecutor", + "configSource": "", + "provider": "doubao-web" + }, + "ddgw": { + "className": "DuckDuckGoWebExecutor", + "configSource": "", + "provider": "duckduckgo-web" + }, + "deepseek-web": { + "className": "DeepSeekWebWithAutoRefreshExecutor", + "configSource": "", + "provider": "deepseek-web" + }, + "devin": { + "className": "DevinCliExecutor", + "configSource": "", + "provider": "devin-cli" + }, + "devin-cli": { + "className": "DevinCliExecutor", + "configSource": "", + "provider": "devin-cli" + }, + "devin-cli-agentic": { + "className": "DevinCliAgenticExecutor", + "configSource": "", + "provider": "devin-cli-agentic" + }, + "devin-desktop": { + "className": "DevinDesktopExecutor", + "configSource": "devin-desktop", + "provider": "devin-desktop" + }, + "doubao-web": { + "className": "DoubaoWebExecutor", + "configSource": "", + "provider": "doubao-web" + }, + "dr": { + "className": "DarioExecutor", + "configSource": "", + "provider": "dario" + }, + "ds-web": { + "className": "DeepSeekWebWithAutoRefreshExecutor", + "configSource": "", + "provider": "deepseek-web" + }, + "duckduckgo-web": { + "className": "DuckDuckGoWebExecutor", + "configSource": "", + "provider": "duckduckgo-web" + }, + "felo": { + "className": "FeloWebExecutor", + "configSource": "", + "provider": "felo-web" + }, + "felo-web": { + "className": "FeloWebExecutor", + "configSource": "", + "provider": "felo-web" + }, + "firefly": { + "className": "AdobeFireflyExecutor", + "configSource": "", + "provider": "adobe-firefly" + }, + "gc": { + "className": "GrokCliExecutor", + "configSource": "grok-cli", + "provider": "grok-cli" + }, + "gembiz": { + "className": "GeminiBusinessExecutor", + "configSource": "", + "provider": "gemini-business" + }, + "gemini-business": { + "className": "GeminiBusinessExecutor", + "configSource": "", + "provider": "gemini-business" + }, + "gemini-web": { + "className": "GeminiWebExecutor", + "configSource": "", + "provider": "gemini-web" + }, + "ghe-copilot": { + "className": "GheCopilotExecutor", + "configSource": "", + "provider": "ghe-copilot" + }, + "github": { + "className": "GithubExecutor", + "configSource": "github", + "provider": "github" + }, + "gitlab": { + "className": "GitlabExecutor", + "configSource": "", + "provider": "gitlab" + }, + "gitlab-duo": { + "className": "GitlabExecutor", + "configSource": "", + "provider": "gitlab-duo" + }, + "glm": { + "className": "GlmExecutor", + "configSource": "glm", + "provider": "glm" + }, + "glm-cn": { + "className": "GlmExecutor", + "configSource": "glm-cn", + "provider": "glm-cn" + }, + "glmt": { + "className": "GlmExecutor", + "configSource": "glmt", + "provider": "glmt" + }, + "grok-cli": { + "className": "GrokCliExecutor", + "configSource": "grok-cli", + "provider": "grok-cli" + }, + "grok-web": { + "className": "GrokWebExecutor", + "configSource": "", + "provider": "grok-web" + }, + "gweb": { + "className": "GeminiWebExecutor", + "configSource": "", + "provider": "gemini-web" + }, + "ha": { + "className": "HyperAgentExecutor", + "configSource": "", + "provider": "hyperagent" + }, + "hailuo-web": { + "className": "HailuoWebExecutor", + "configSource": "", + "provider": "hailuo-web" + }, + "hc": { + "className": "HuggingChatExecutor", + "configSource": "", + "provider": "huggingchat" + }, + "huggingchat": { + "className": "HuggingChatExecutor", + "configSource": "", + "provider": "huggingchat" + }, + "hyperagent": { + "className": "HyperAgentExecutor", + "configSource": "", + "provider": "hyperagent" + }, + "in-ai": { + "className": "InnerAiExecutor", + "configSource": "", + "provider": "inner-ai" + }, + "inner-ai": { + "className": "InnerAiExecutor", + "configSource": "", + "provider": "inner-ai" + }, + "kimi": { + "className": "MoonshotExecutor", + "configSource": "kimi", + "provider": "kimi" + }, + "kimi-coding": { + "className": "KimiExecutor", + "configSource": "kimi-coding", + "provider": "kimi-coding" + }, + "kimi-coding-apikey": { + "className": "KimiExecutor", + "configSource": "kimi-coding-apikey", + "provider": "kimi-coding-apikey" + }, + "kimi-web": { + "className": "KimiWebExecutor", + "configSource": "", + "provider": "kimi-web" + }, + "kiro": { + "className": "KiroExecutor", + "configSource": "kiro", + "provider": "kiro" + }, + "lma": { + "className": "LMArenaExecutor", + "configSource": "", + "provider": "lmarena" + }, + "lmarena": { + "className": "LMArenaExecutor", + "configSource": "", + "provider": "lmarena" + }, + "microsoft-designer-web": { + "className": "MicrosoftDesignerWebExecutor", + "configSource": "", + "provider": "microsoft-designer-web" + }, + "moonshot": { + "className": "MoonshotExecutor", + "configSource": "moonshot", + "provider": "moonshot" + }, + "ms-web": { + "className": "MuseSparkWebExecutor", + "configSource": "", + "provider": "muse-spark-web" + }, + "msdesigner": { + "className": "MicrosoftDesignerWebExecutor", + "configSource": "", + "provider": "microsoft-designer-web" + }, + "muse-spark-web": { + "className": "MuseSparkWebExecutor", + "configSource": "", + "provider": "muse-spark-web" + }, + "nlpcloud": { + "className": "NlpCloudExecutor", + "configSource": "nlpcloud", + "provider": "nlpcloud" + }, + "notion-web": { + "className": "NotionWebExecutor", + "configSource": "", + "provider": "notion-web" + }, + "nr": { + "className": "NineRouterExecutor", + "configSource": "", + "provider": "9router" + }, + "nw": { + "className": "NotionWebExecutor", + "configSource": "", + "provider": "notion-web" + }, + "opencode": { + "className": "OpencodeExecutor", + "configSource": "opencode-zen", + "provider": "opencode-zen" + }, + "opencode-go": { + "className": "OpencodeExecutor", + "configSource": "opencode-go", + "provider": "opencode-go" + }, + "opencode-zen": { + "className": "OpencodeExecutor", + "configSource": "opencode-zen", + "provider": "opencode-zen" + }, + "pepper": { + "className": "ChipotleExecutor", + "configSource": "", + "provider": "chipotle" + }, + "perplexity-web": { + "className": "PerplexityWebExecutor", + "configSource": "", + "provider": "perplexity-web" + }, + "poe-web": { + "className": "PoeWebExecutor", + "configSource": "", + "provider": "poe-web" + }, + "pol": { + "className": "PollinationsExecutor", + "configSource": "pollinations", + "provider": "pollinations" + }, + "pollinations": { + "className": "PollinationsExecutor", + "configSource": "pollinations", + "provider": "pollinations" + }, + "pplx-web": { + "className": "PerplexityWebExecutor", + "configSource": "", + "provider": "perplexity-web" + }, + "pql": { + "className": "PromptQlExecutor", + "configSource": "", + "provider": "promptql" + }, + "promptql": { + "className": "PromptQlExecutor", + "configSource": "", + "provider": "promptql" + }, + "qoder": { + "className": "QoderExecutor", + "configSource": "qoder", + "provider": "qoder" + }, + "qw": { + "className": "QwenWebExecutor", + "configSource": "", + "provider": "qwen-web" + }, + "qwen-web": { + "className": "QwenWebExecutor", + "configSource": "", + "provider": "qwen-web" + }, + "raycast": { + "className": "RaycastExecutor", + "configSource": "raycast", + "provider": "raycast" + }, + "rc": { + "className": "RaycastExecutor", + "configSource": "raycast", + "provider": "raycast" + }, + "t3-web": { + "className": "T3ChatWebExecutor", + "configSource": "", + "provider": "t3-web" + }, + "t3chat": { + "className": "T3ChatWebExecutor", + "configSource": "", + "provider": "t3-web" + }, + "tasw": { + "className": "TencentAIStudioWebExecutor", + "configSource": "", + "provider": "tencent-aistudio-web" + }, + "tcw": { + "className": "TinyCmsExecutor", + "configSource": "", + "provider": "tinycms-web" + }, + "tencent-aistudio-web": { + "className": "TencentAIStudioWebExecutor", + "configSource": "", + "provider": "tencent-aistudio-web" + }, + "theoldllm": { + "className": "TheOldLlmExecutor", + "configSource": "", + "provider": "theoldllm" + }, + "tinycms-web": { + "className": "TinyCmsExecutor", + "configSource": "", + "provider": "tinycms-web" + }, + "tllm": { + "className": "TheOldLlmExecutor", + "configSource": "", + "provider": "theoldllm" + }, + "trae": { + "className": "TraeExecutor", + "configSource": "trae", + "provider": "trae" + }, + "v0": { + "className": "V0VercelWebExecutor", + "configSource": "", + "provider": "v0-vercel-web" + }, + "v0-vercel-web": { + "className": "V0VercelWebExecutor", + "configSource": "", + "provider": "v0-vercel-web" + }, + "ven": { + "className": "VeniceWebExecutor", + "configSource": "", + "provider": "venice-web" + }, + "venice-web": { + "className": "VeniceWebExecutor", + "configSource": "", + "provider": "venice-web" + }, + "veo-free": { + "className": "VeoAIFreeWebExecutor", + "configSource": "", + "provider": "veoaifree-web" + }, + "veoaifree-web": { + "className": "VeoAIFreeWebExecutor", + "configSource": "", + "provider": "veoaifree-web" + }, + "vertex": { + "className": "VertexExecutor", + "configSource": "vertex", + "provider": "vertex" + }, + "vertex-partner": { + "className": "VertexExecutor", + "configSource": "vertex", + "provider": "vertex" + }, + "xai": { + "className": "XaiExecutor", + "configSource": "xai", + "provider": "xai" + }, + "xai-oauth": { + "className": "XaiExecutor", + "configSource": "xai-oauth", + "provider": "xai-oauth" + }, + "xao": { + "className": "XaiExecutor", + "configSource": "xai-oauth", + "provider": "xai-oauth" + }, + "ybw": { + "className": "YuanbaoWebExecutor", + "configSource": "", + "provider": "yuanbao-web" + }, + "yuanbao-web": { + "className": "YuanbaoWebExecutor", + "configSource": "", + "provider": "yuanbao-web" + }, + "zai-web": { + "className": "ZaiWebExecutor", + "configSource": "", + "provider": "zai-web" + }, + "zc": { + "className": "ZcodeExecutor", + "configSource": "", + "provider": "zcode" + }, + "zcode": { + "className": "ZcodeExecutor", + "configSource": "", + "provider": "zcode" + }, + "zed-hosted": { + "className": "ZedHostedExecutor", + "configSource": "zed-hosted", + "provider": "zed-hosted" + }, + "zenmux-free": { + "className": "ZenmuxFreeExecutor", + "configSource": "", + "provider": "zenmux-free" + }, + "zmf": { + "className": "ZenmuxFreeExecutor", + "configSource": "", + "provider": "zenmux-free" + }, + "zw": { + "className": "ZaiWebExecutor", + "configSource": "", + "provider": "zai-web" + } + }, + "keyCount": 137, + "sharedInstances": [] +} diff --git a/tests/unit/executor-map-golden.test.ts b/tests/unit/executor-map-golden.test.ts new file mode 100644 index 0000000000..ea83e56756 --- /dev/null +++ b/tests/unit/executor-map-golden.test.ts @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// R0.3 GOLDEN LOCK (characterization BEFORE the ExecutorRegistry refactor): +// freeze the full provider-id → executor mapping of open-sse/executors/index.ts — +// every specialized key with its executor class, effective provider identity and +// which PROVIDERS config entry backs it — plus the getExecutor() dispatch rules +// (specialized hit, DefaultExecutor fallback + cache, cloud-agent guard #6699, +// search-provider guard #10274). The registry refactor must keep this snapshot +// byte-identical: any drift in keys, classes, provider identity or guard behavior +// is a golden diff, not a silent routing change. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-executor-golden-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Dynamic imports AFTER DATA_DIR is set so db/core.ts picks up the temp path. +const { getExecutor, hasSpecializedExecutor, DefaultExecutor } = await import( + "../../open-sse/executors/index.ts" +); +const { PROVIDERS } = await import("../../open-sse/config/constants.ts"); +const { SEARCH_PROVIDERS } = await import("../../open-sse/config/searchRegistry.ts"); +const { goldenSnapshot } = await import("../helpers/goldenSnapshot.ts"); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// The specialized keys are not exported; enumerate them through the public +// surface by probing every plausible id source AND the literal keys read from +// the module source. Reading the source keeps the golden honest: a key added +// to (or removed from) the hard-coded map cannot hide from the snapshot. +function readSpecializedKeys(): string[] { + const src = fs.readFileSync( + path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../open-sse/executors/index.ts"), + "utf8" + ); + const mapMatch = src.match(/const executors = \{([\s\S]*?)\n\};/); + assert.ok(mapMatch, "executors map literal not found in open-sse/executors/index.ts"); + const keys: string[] = []; + for (const line of mapMatch[1].split("\n")) { + const m = line.match(/^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+)):\s*new /); + if (m) keys.push(m[1] ?? m[2]); + } + return keys; +} + +// Map a ProviderConfig object back to its PROVIDERS key by identity, so the +// snapshot records WHICH config backs each executor without freezing the whole +// (huge, frequently-edited) config content. +const providerConfigKeyByRef = new Map(); +for (const [key, cfg] of Object.entries(PROVIDERS)) { + if (cfg && typeof cfg === "object" && !providerConfigKeyByRef.has(cfg)) { + providerConfigKeyByRef.set(cfg, key); + } +} + +function describeExecutor(instance: unknown): { + className: string; + provider: string | null; + configSource: string | null; +} { + const inst = instance as { constructor: { name: string }; provider?: string; config?: object }; + const cfg = inst.config; + return { + className: inst.constructor.name, + provider: typeof inst.provider === "string" ? inst.provider : null, + configSource: + cfg == null ? null : (providerConfigKeyByRef.get(cfg) ?? ""), + }; +} + +const specializedKeys = readSpecializedKeys(); + +test("golden: specialized executor map — key → class + provider identity + config source", () => { + assert.ok(specializedKeys.length >= 100, `suspiciously few keys: ${specializedKeys.length}`); + + const entries: Record< + string, + { className: string; provider: string | null; configSource: string | null } + > = {}; + const byInstance = new Map(); + + for (const key of [...specializedKeys].sort()) { + assert.equal(hasSpecializedExecutor(key), true, `hasSpecializedExecutor(${key})`); + const instance = getExecutor(key); + entries[key] = describeExecutor(instance); + const group = byInstance.get(instance) ?? []; + group.push(key); + byInstance.set(instance, group); + } + + // Keys sharing the SAME instance share per-instance state (session pools, + // rotation cooldowns); today every map entry is its own `new X()`. Freeze that. + const sharedInstances = [...byInstance.values()] + .filter((keys) => keys.length > 1) + .map((keys) => keys.sort()) + .sort((a, b) => a[0].localeCompare(b[0])); + + goldenSnapshot("executors/executor-map", { + keyCount: specializedKeys.length, + entries, + sharedInstances, + }); +}); + +test("golden: getExecutor dispatch rules — fallback, cache and 400-guards", () => { + // 1. Unknown provider → DefaultExecutor for that provider, memoized. + const unknown = "golden-test-unknown-provider"; + assert.equal(hasSpecializedExecutor(unknown), false); + const fallback = getExecutor(unknown); + assert.ok(fallback instanceof DefaultExecutor, "fallback must be DefaultExecutor"); + assert.equal(getExecutor(unknown), fallback, "DefaultExecutor fallback must be cached"); + + // 2. Cloud-agent guard (#6699) and search guard (#10274) → status-400 throw. + const guardOutcome = (provider: string) => { + try { + getExecutor(provider); + return { throws: false as const }; + } catch (err) { + const e = err as Error & { status?: number }; + return { throws: true as const, status: e.status ?? null, message: e.message }; + } + }; + + const searchProviders = Object.keys(SEARCH_PROVIDERS).sort(); + goldenSnapshot("executors/dispatch-rules", { + fallback: describeExecutor(fallback), + cloudAgentGuard: { jules: guardOutcome("jules") }, + searchGuard: Object.fromEntries(searchProviders.map((p) => [p, guardOutcome(p)])), + }); +}); diff --git a/tests/unit/executor-registry.test.ts b/tests/unit/executor-registry.test.ts new file mode 100644 index 0000000000..c05128681b --- /dev/null +++ b/tests/unit/executor-registry.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// R0.3 — unit tests for the ExecutorRegistry seam itself (registration +// semantics + wiring of the built-ins). Behavior parity of the full map is +// covered separately by tests/unit/executor-map-golden.test.ts. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-executor-registry-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { registerExecutor, getRegisteredExecutor, hasRegisteredExecutor, listExecutorAliases } = + await import("../../open-sse/executors/registry.ts"); +const { getExecutor, hasSpecializedExecutor, BaseExecutor, DefaultExecutor } = await import( + "../../open-sse/executors/index.ts" +); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("built-ins are registered at module load and resolve through the registry", () => { + const aliases = listExecutorAliases(); + assert.ok(aliases.length >= 100, `expected the built-in table, got ${aliases.length} aliases`); + for (const alias of ["antigravity", "kiro", "glm", "9router", "conol-web"]) { + assert.ok(hasRegisteredExecutor(alias), `missing built-in: ${alias}`); + assert.equal(getExecutor(alias), getRegisteredExecutor(alias)); + assert.ok(getExecutor(alias) instanceof BaseExecutor); + } +}); + +test("registerExecutor throws on duplicate alias", () => { + assert.throws(() => registerExecutor("kiro", getRegisteredExecutor("kiro")!), { + message: /already registered: "kiro"/, + }); +}); + +test("registering a new alias makes it resolvable via getExecutor and hasSpecializedExecutor", () => { + const alias = "registry-test-provider"; + assert.equal(hasSpecializedExecutor(alias), false); + const instance = new DefaultExecutor(alias); + registerExecutor(alias, instance); + assert.equal(hasSpecializedExecutor(alias), true); + assert.equal(getExecutor(alias), instance); +}); + +test("registry lookup is exact — Object.prototype names are not executors", () => { + // The old object-literal lookup (`executors[provider]`) leaked prototype + // members: getExecutor("constructor") returned Object's constructor. The Map + // registry must treat these as unknown providers (DefaultExecutor fallback). + for (const name of ["constructor", "toString", "hasOwnProperty", "__proto__"]) { + assert.equal(hasSpecializedExecutor(name), false, name); + assert.ok(getExecutor(name) instanceof DefaultExecutor, name); + } +});