mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Update routing workflows and supporting configuration
This commit is contained in:
@@ -69,7 +69,7 @@ export const cssOutput = path.join(rendererAssetsDir, "main.css");
|
||||
export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js");
|
||||
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
|
||||
export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.ts");
|
||||
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
|
||||
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js", "media-tools-proxy-mcp.js"];
|
||||
const lightweightMcpBundleMaxBytes = 128 * 1024;
|
||||
const forbiddenLightweightMcpInputs = [
|
||||
{ prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" },
|
||||
@@ -219,6 +219,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
|
||||
path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
|
||||
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
|
||||
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
|
||||
@@ -249,6 +250,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {}
|
||||
path.join(cliSourceRoot, "cli.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
|
||||
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
|
||||
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
|
||||
@@ -276,6 +278,7 @@ export function createCoreServerBuildOptions({ mode = "production", plugins = []
|
||||
path.join(coreSourceRoot, "entrypoints", "server.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
|
||||
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
|
||||
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
|
||||
|
||||
@@ -25,6 +25,7 @@ const testProjects = {
|
||||
core: {
|
||||
runtimeEntryPoints: {
|
||||
"runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"),
|
||||
"runtime/media-tools-proxy-mcp": path.join(packageRoots.core, "mcp", "media-tools-proxy-mcp.ts"),
|
||||
"runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"),
|
||||
"runtime/route-script-worker": path.join(packageRoots.core, "routing", "route-script-worker.ts"),
|
||||
"runtime/upstream-header-sanitizer": path.join(packageRoots.core, "gateway", "core-runtime", "upstream-header-sanitizer.ts"),
|
||||
|
||||
@@ -2,19 +2,86 @@
|
||||
title: Custom MCP Tool
|
||||
pageTitle: Custom MCP Tool
|
||||
eyebrow: Fusion
|
||||
lead: Connect local or remote MCP tools to a Fusion model.
|
||||
lead: Connect built-in, local, or remote MCP tools to a Fusion model.
|
||||
---
|
||||
|
||||
## Entry Point
|
||||
|
||||
Click **Add custom MCP** and select a transport.
|
||||
Choose a built-in tool under **Tools**, or click **Add custom MCP** to connect a custom service.
|
||||
|
||||
## Transports
|
||||
Custom MCP supports:
|
||||
|
||||
- **stdio**: local command-line tools.
|
||||
- **streamable-http / sse**: remote MCP services.
|
||||
- **Discover tools**: read tools exposed by the MCP server.
|
||||
- **Discover tools**: read tools exposed by an MCP server.
|
||||
|
||||
## Image and Video Generation
|
||||
|
||||
Media is exposed as two ordinary built-in Fusion tools: **Image generation** and **Video generation**. They behave like the built-in search tool, do not belong to ToolHub, and do not create a separate section on the Fusion page.
|
||||
|
||||
Fusion tool loops do not have a turn-count or tool-call-count limit. Request timeout and client cancellation still apply.
|
||||
|
||||
Each tool has one model selector:
|
||||
|
||||
- Selecting `Provider/model` sends the request through ai-gateway, which applies the configured endpoint, active credential, extra headers, and extra body. The media tool never asks for a separate xAI API key.
|
||||
- An imported Grok Agent automatically contributes `grok-imagine-image-quality` and `grok-imagine-video`. ai-gateway reuses its existing OAuth login to access `api.x.ai`; Grok CLI is not started.
|
||||
- Image and video models are independent, and separate Fusion profiles may bind different media models.
|
||||
|
||||
To configure media:
|
||||
|
||||
1. Add a provider and models that support the image or video generation protocol on the **Providers** page, or import a logged-in Grok Agent.
|
||||
2. Add **Image generation**, **Video generation**, or both under a Fusion model's **Tools**.
|
||||
3. Select a model below each tool and save the Fusion model.
|
||||
4. Use that Fusion model as an agent model or routing target.
|
||||
|
||||
CCR calls providers through ai-gateway's generic media protocol: `images/generations` and `images/edits` for images, and `videos/generations` plus `videos/{id}` for videos. The selectors show provider models with a declared or detected matching media capability; Grok API is one supported implementation.
|
||||
|
||||
## Runtime Tools
|
||||
|
||||
CCR creates profile-specific runtime tool names when the Fusion model is saved. This prevents model bindings from colliding across Fusion profiles.
|
||||
|
||||
| Fusion tool | Runtime capabilities |
|
||||
| --- | --- |
|
||||
| Image generation | Generate images and edit one to three local images. |
|
||||
| Video generation | Start text/image/reference video jobs, then inspect or cancel asynchronous jobs. |
|
||||
|
||||
Paid submissions accept an optional `idempotency_key`. Reuse one stable key for a user intent to avoid duplicate billing during network retries. Video submission always returns a job ID immediately.
|
||||
|
||||
The API backend supports image generation, image editing, text-to-video, image-to-video, and reference-to-video. Media execution never launches a nested Agent or CLI process.
|
||||
|
||||
## Artifacts and Safety
|
||||
|
||||
Artifacts are stored in CCR's private data directory and include a local path, MIME type, size, SHA-256, and expiring URL. Video URLs support HTTP Range. Retention, concurrency, and timeout are internal CCR safety policies and are not requested in the Fusion UI.
|
||||
|
||||
Local image inputs still undergo canonical-path, file-signature, and size checks. A scoped current working directory, the system temporary directory, and the CCR config directory are allowed by default. Filesystem roots, the user home directory, and directories above the user home are never trusted implicitly; add an explicit `allowedInputRoots` entry when broader access is intentional. The UI does not expose an “Allowed image roots” field.
|
||||
|
||||
To connect an MCP client directly instead of using Fusion:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3456/__ccr/media/mcp
|
||||
Authorization: Bearer <CCR API Key>
|
||||
```
|
||||
|
||||
The endpoint uses a CCR API key, while artifact URLs use separate expiring tokens. Legacy `/__ccr/grok-media/*` routes remain available for migration.
|
||||
Internally, Fusion registers these tools through a `stdio` MCP proxy generated with the Core configuration. The proxy returns the profile's deterministic tool catalog directly and forwards actual calls to the private endpoint above, avoiding missing tools caused by HTTP MCP discovery or startup ordering.
|
||||
|
||||
Internal policy example (normally no manual changes are needed):
|
||||
|
||||
```json
|
||||
{
|
||||
"mediaTools": {
|
||||
"enabled": true,
|
||||
"artifactTtlHours": 24,
|
||||
"jobTimeoutMs": 600000,
|
||||
"maxImageConcurrency": 2,
|
||||
"maxVideoConcurrency": 1,
|
||||
"allowedInputRoots": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Legacy `grokMedia` input is migrated to `mediaTools` when loaded. A legacy `grok-cli` media binding is resolved to an imported Grok Agent or a configured Grok API model; it never starts the CLI.
|
||||
|
||||
## Verification
|
||||
|
||||
Validate risky or slow tools in a dedicated Agent Config before using them in production routing.
|
||||
Verify that the selected provider implements the relevant media endpoints with a test Fusion profile before using it in production routing.
|
||||
|
||||
@@ -9,9 +9,9 @@ lead: Choose the model for a request, then automatically retry or switch to fall
|
||||
|
||||
### Claude Code
|
||||
|
||||
The built-in Claude Code route detects requests from Claude Code and routes main requests to the Claude Code Agent Config model.
|
||||
The built-in Claude Code route detects requests from Claude Code and routes main requests to the Claude Code Agent Config model when the client has not selected a recognized model.
|
||||
|
||||
Claude Code **main requests** use the Claude Code Agent Config model. If that model is unset, the built-in route remains inactive. CCR also automatically removes the first `x-anthropic-billing-header` system message injected by Claude Code so that billing helper messages do not affect later routing decisions. Claude Code Subagent, Task, and Workflow-created agents can still choose different models through the tag mechanism below.
|
||||
Claude Code **main requests** prefer an explicit client-selected model that CCR recognizes. The Agent Config model is only the default when the client model is missing or unrecognized; if it is unset, the built-in route remains inactive. User-configured routing rules can still rewrite the model. CCR also automatically removes the first `x-anthropic-billing-header` system message injected by Claude Code so that billing helper messages do not affect later routing decisions. Claude Code Subagent, Task, and Workflow-created agents can still choose different models through the tag mechanism below.
|
||||
|
||||
#### Subagent / Workflow Auto-Routing
|
||||
|
||||
@@ -39,7 +39,7 @@ Recommended setup:
|
||||
|
||||
1. Add usable models under **Providers**, and verify that the model IDs can be requested.
|
||||
2. Open **Models** and fill Description for the models you want Subagents to choose automatically. Describe task fit, speed, cost, and limits.
|
||||
3. Enable a Claude Code config under **Agent Config**, and choose the main model. This model handles the main Claude Code conversation.
|
||||
3. Enable a Claude Code config under **Agent Config**, and choose the default model. Claude Code uses it when the client has not selected a recognized model.
|
||||
4. Confirm that the built-in **Claude Code** route is enabled on the **Routing** page.
|
||||
5. Use Agent, Task, or Workflow in Claude Code. When Claude Code spawns an agent, it can choose a CCR model from the descriptions and write the tag.
|
||||
|
||||
|
||||
@@ -2,20 +2,86 @@
|
||||
title: 自定义 MCP 工具
|
||||
pageTitle: 自定义 MCP 工具
|
||||
eyebrow: Fusion
|
||||
lead: 将本地或远程 MCP 工具接入 Fusion 模型。
|
||||
lead: 将内置工具、本地或远程 MCP 工具接入 Fusion 模型。
|
||||
---
|
||||
|
||||
## 添加入口
|
||||
|
||||
点击 **Add custom MCP** 后选择 transport。
|
||||
在 Fusion 模型的 **Tools** 中选择内置工具,或点击 **Add custom MCP** 接入自定义服务。
|
||||
|
||||
## Transport
|
||||
自定义 MCP 支持:
|
||||
|
||||
- **stdio**:本地命令行工具。
|
||||
- **streamable-http / sse**:远程 MCP 服务。
|
||||
- **Discover tools**:读取 MCP server 暴露的工具。
|
||||
|
||||
## 图片生成与视频生成
|
||||
|
||||
媒体能力以 **图片生成** 和 **视频生成** 两个普通的 Fusion 内置工具提供,使用方式与内置搜索工具一致,不属于 ToolHub,也没有单独的 Fusion 配置板块。
|
||||
|
||||
Fusion 工具循环不再设置轮次上限或工具调用次数上限;请求超时和客户端取消仍然生效。
|
||||
|
||||
每个工具只配置一个模型:
|
||||
|
||||
- 选择 `供应商/模型` 时,CCR 将请求交给 ai-gateway;供应商地址、凭据、额外请求头和请求体都由网关统一应用。媒体工具不会再次要求输入 xAI API Key。
|
||||
- 导入 Grok Agent 后会自动提供 `grok-imagine-image-quality` 和 `grok-imagine-video`。ai-gateway 复用已有 OAuth 登录态访问 `api.x.ai`,不会启动 Grok CLI。
|
||||
- 图片模型和视频模型彼此独立;不同 Fusion 模型也可以选择不同的媒体模型。
|
||||
|
||||
配置步骤:
|
||||
|
||||
1. 在 **供应商** 页面配置支持图片/视频生成协议的供应商及模型,或导入已登录的 Grok Agent。
|
||||
2. 新建或编辑 Fusion 模型,在 **Tools** 中添加 **图片生成**、**视频生成**,或同时添加两者。
|
||||
3. 在对应工具下选择模型,保存 Fusion 模型。
|
||||
4. 将该 Fusion 模型选为 Agent 模型或路由目标。
|
||||
|
||||
CCR 通过 ai-gateway 的通用媒体协议调用供应商:图片使用 `images/generations` 与 `images/edits`,视频使用 `videos/generations` 与 `videos/{id}`。模型选择器显示声明或检测到对应媒体能力的供应商模型,Grok API 只是其中一种实现。
|
||||
|
||||
## 运行时工具
|
||||
|
||||
保存 Fusion 模型后,CCR 为该模型生成独立的运行时工具名,防止多个 Fusion 配置之间的模型绑定互相覆盖。
|
||||
|
||||
| Fusion 工具 | 运行时能力 |
|
||||
| --- | --- |
|
||||
| 图片生成 | 生成图片;编辑 1–3 张本地图片。 |
|
||||
| 视频生成 | 启动文本/图片/参考图生视频任务;查询或取消异步任务。 |
|
||||
|
||||
付费提交接受可选 `idempotency_key`。一次用户意图应复用稳定的 Key,避免网络重试产生重复计费。视频始终异步执行,启动调用会立即返回 Job ID。
|
||||
|
||||
API 后端支持生图、图片编辑、文生视频、图生视频和参考图生视频。媒体执行不会再启动嵌套 Agent 或 CLI 进程。
|
||||
|
||||
## 产物与安全
|
||||
|
||||
生成产物保存在 CCR 私有数据目录,结果包含本地路径、MIME、大小、SHA-256 和限时 URL;视频 URL 支持 HTTP Range。保留期、并发和超时是 CCR 的内部安全策略,不在 Fusion UI 中要求用户配置。
|
||||
|
||||
本地图片仍会校验真实路径、文件头和大小。CCR 默认允许范围明确的当前工作目录、系统临时目录和 CCR 配置目录;文件系统根目录、用户主目录及其上级目录不会被隐式信任。确实需要扩大范围时,请显式配置 `allowedInputRoots`。UI 不再显示“允许读取图片的目录”。
|
||||
|
||||
需要绕过 Fusion 直接接入 MCP 客户端时,可以连接:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3456/__ccr/media/mcp
|
||||
Authorization: Bearer <CCR API Key>
|
||||
```
|
||||
|
||||
该端点使用 CCR API Key;产物 URL 使用独立限时 token。旧 `/__ccr/grok-media/*` 路径仍作为迁移兼容入口。
|
||||
Fusion 内部通过随 Core 配置生成的 `stdio` MCP 代理注册这些工具;代理直接返回当前 Profile 的确定工具清单,再将实际调用转发到上述私有端点,避免 HTTP MCP 发现失败或启动时序导致工具缺失。
|
||||
|
||||
内部策略配置示例(通常不需要手动修改):
|
||||
|
||||
```json
|
||||
{
|
||||
"mediaTools": {
|
||||
"enabled": true,
|
||||
"artifactTtlHours": 24,
|
||||
"jobTimeoutMs": 600000,
|
||||
"maxImageConcurrency": 2,
|
||||
"maxVideoConcurrency": 1,
|
||||
"allowedInputRoots": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
旧 `grokMedia` 配置会在读取时迁移到 `mediaTools`。旧 `grok-cli` 媒体绑定会解析到已导入的 Grok Agent 或已配置的 Grok API 模型,不会再启动 CLI。
|
||||
|
||||
## 验证建议
|
||||
|
||||
高风险或响应慢的 MCP 工具建议先在独立配置中验证。
|
||||
|
||||
先使用一个测试 Fusion 模型验证供应商是否实现对应媒体端点,再用于生产路由。
|
||||
|
||||
@@ -9,9 +9,9 @@ lead: 设置请求如何选择模型,并在失败时通过 Fallback 自动重
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code 内置路由的作用是识别 Claude Code 发来的请求,并把主请求路由到 Claude Code Agent 配置中的模型。
|
||||
Claude Code 内置路由的作用是识别 Claude Code 发来的请求,并在客户端没有选择可识别模型时,把主请求路由到 Claude Code Agent 配置中的模型。
|
||||
|
||||
Claude Code **主请求** 使用 Claude Code Agent 配置中的模型;如果未设置,该内置路由不会生效。CCR 也会自动删除 Claude Code 注入的第一条 `x-anthropic-billing-header` system 消息,避免这类计费辅助消息影响后续路由判断。Claude Code 创建的 Subagent、Task 或 Workflow 内部 Agent 可以继续用下面的标签机制自动选择不同模型。
|
||||
Claude Code **主请求** 会优先使用客户端显式选择且 CCR 能识别的模型。Agent 配置中的模型只在客户端模型缺失或无法识别时作为默认模型;如果未设置,该内置路由不会生效。用户配置的路由规则仍可改写模型。CCR 也会自动删除 Claude Code 注入的第一条 `x-anthropic-billing-header` system 消息,避免这类计费辅助消息影响后续路由判断。Claude Code 创建的 Subagent、Task 或 Workflow 内部 Agent 可以继续用下面的标签机制自动选择不同模型。
|
||||
|
||||
#### Subagent / Workflow 自动路由
|
||||
|
||||
@@ -39,7 +39,7 @@ Claude Code 的 Agent / Task / Workflow 可以派生新的模型请求。CCR 使
|
||||
|
||||
1. 在 **供应商** 中添加可用模型,确认模型 ID 可以真实请求。
|
||||
2. 打开 **模型** 页面,为希望 Subagent 自动选择的模型填写 Description。说明要写清模型适合的任务、速度、成本和限制。
|
||||
3. 在 **Agent配置** 中启用 Claude Code 配置,并设置主模型。这个模型负责 Claude Code 主会话。
|
||||
3. 在 **Agent配置** 中启用 Claude Code 配置,并设置默认模型。Claude Code 未选择可识别模型时会使用它。
|
||||
4. 在 **路由** 页面确认 **Claude Code** 内置路由已启用。
|
||||
5. 在 Claude Code 中使用 Agent、Task 或 Workflow。需要派生 Agent 时,Claude Code 会根据模型 Description 选择一个 CCR 模型并写入标签。
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
Generated
+6
-6
@@ -12,7 +12,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@the-next-ai/ai-gateway": "^1.0.12",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"electron-updater": "^6.8.9",
|
||||
@@ -2310,9 +2310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@the-next-ai/ai-gateway": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.9.tgz",
|
||||
"integrity": "sha512-/nt/1ZciUgarfyJW6itfDIgKMa8HOgmt0RyPyvmUCDcbJGEEl0BNmd85Zz5boSwT70xcjNojds9DPsQXG7N+sQ==",
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.12.tgz",
|
||||
"integrity": "sha512-o092FXo18NwXyetqeS192jBH0dmrklpsFyU3LlOUloXOHODVraniYOUAVXzrhTYzD6pPqvVxAjI/6K2PY6hBQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"diff": "^8.0.3",
|
||||
@@ -9545,7 +9545,7 @@
|
||||
"version": "3.0.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@the-next-ai/ai-gateway": "^1.0.11",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
@@ -9562,7 +9562,7 @@
|
||||
"name": "@claude-code-router/core",
|
||||
"version": "3.0.6",
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@the-next-ai/ai-gateway": "^1.0.11",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@
|
||||
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@the-next-ai/ai-gateway": "^1.0.12",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"electron-updater": "^6.8.9",
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"test:integration": "node ../../build/test.mjs cli --scope integration && node ../../build/run-tests.mjs cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@the-next-ai/ai-gateway": "^1.0.11",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
|
||||
+24
-19
@@ -6,14 +6,14 @@ import path from "node:path";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
|
||||
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch";
|
||||
import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
|
||||
import { codexDesktopAppName, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
|
||||
import { loadAppConfig } from "@ccr/core/config/config";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service";
|
||||
import { ensureProfileGateway, ProfileGatewayUnavailableError } from "@ccr/core/profiles/launch-service";
|
||||
import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface, shouldAutoStartProfileGateway } from "@ccr/core/profiles/launch-core";
|
||||
import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server";
|
||||
import { assertAvailableGatewayModels, type AppConfig, type GatewayStatus, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app";
|
||||
import { assertAvailableGatewayModels, type AppConfig, type GatewayStatus, type ProfileConfig, type ProfileOpenResult, type ProfileOpenSurface } from "@ccr/core/contracts/app";
|
||||
|
||||
type ProfileCliOptions = {
|
||||
agentArgs: string[];
|
||||
@@ -120,6 +120,20 @@ async function main(): Promise<void> {
|
||||
if (profile.agent === "claude-code" && resolvedSurface === "app" && profileOptions.agentArgs.length > 0) {
|
||||
throw new Error("Claude App profiles do not support agent arguments.");
|
||||
}
|
||||
if (profile.agent === "codex" && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
|
||||
const state = await startService({
|
||||
command: "start",
|
||||
daemonChild: false,
|
||||
ensureGatewayRunning: true,
|
||||
help: false,
|
||||
open: false,
|
||||
profileManaged: false,
|
||||
startGateway: true
|
||||
});
|
||||
const opened = await callServiceRpc<ProfileOpenResult>(state, "openProfile", [{ profileId: profile.id, surface: "app" }], 30_000);
|
||||
process.stdout.write(`${opened.message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const autoStartProfileGateway = shouldAutoStartProfileGateway(profile, resolvedSurface);
|
||||
let profileGatewayLease = autoStartProfileGateway ? acquireManagedProfileGatewayLease() : undefined;
|
||||
@@ -174,22 +188,13 @@ async function main(): Promise<void> {
|
||||
process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`);
|
||||
return;
|
||||
}
|
||||
if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
|
||||
if (profile.agent === "zcode") {
|
||||
const launch = launchZcodeAppProfile(configDir, profile, launchConfig);
|
||||
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
|
||||
if (spawnError) {
|
||||
throw new Error(`Failed to open ZCode App: ${spawnError}`);
|
||||
}
|
||||
process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`);
|
||||
} else {
|
||||
const launch = launchCodexAppProfile(configDir, profile, launchConfig);
|
||||
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
|
||||
if (spawnError) {
|
||||
throw new Error(`Failed to open ${codexDesktopAppName}: ${spawnError}`);
|
||||
}
|
||||
process.stdout.write(`Opened ${codexDesktopAppName} with ${profile.name || profile.id}.\n`);
|
||||
if (profile.agent === "zcode" && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
|
||||
const launch = launchZcodeAppProfile(configDir, profile, launchConfig);
|
||||
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
|
||||
if (spawnError) {
|
||||
throw new Error(`Failed to open ZCode App: ${spawnError}`);
|
||||
}
|
||||
process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -927,7 +932,7 @@ async function waitForServiceUnavailable(state: ServiceState, timeoutMs: number)
|
||||
return appInfo?.name !== "Claude Code Router";
|
||||
}
|
||||
|
||||
async function callServiceRpc<T>(state: ServiceState, method: string, args: unknown[] = []): Promise<T> {
|
||||
async function callServiceRpc<T>(state: ServiceState, method: string, args: unknown[] = [], timeoutMs = serviceRpcTimeoutMs): Promise<T> {
|
||||
const endpoint = serviceRpcEndpoint(state.url);
|
||||
const authToken = serviceAuthToken(state.url);
|
||||
if (!endpoint || !authToken) {
|
||||
@@ -935,7 +940,7 @@ async function callServiceRpc<T>(state: ServiceState, method: string, args: unkn
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), serviceRpcTimeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
body: JSON.stringify({ args, method }),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"test:integration": "node ../../build/test.mjs core --scope integration && node ../../build/run-tests.mjs core"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@the-next-ai/ai-gateway": "^1.0.11",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { WebSocket } from "undici";
|
||||
|
||||
type CdpError = {
|
||||
code?: number;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type CdpMessage = {
|
||||
error?: CdpError;
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
type CdpClientOptions = {
|
||||
connectTimeoutMs?: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export class CdpClient {
|
||||
private closedSettled = false;
|
||||
private readonly closePromise: Promise<void>;
|
||||
private closeResolve!: () => void;
|
||||
private readonly handlers = new Map<string, Array<(params: unknown) => void>>();
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (value: unknown) => void;
|
||||
}>();
|
||||
|
||||
private constructor(
|
||||
private readonly ws: WebSocket,
|
||||
private readonly label: string
|
||||
) {
|
||||
this.closePromise = new Promise((resolve) => {
|
||||
this.closeResolve = resolve;
|
||||
});
|
||||
ws.addEventListener("message", (event) => this.handleMessage(event.data));
|
||||
ws.addEventListener("close", () => this.finishClose(new Error(`${this.label} CDP WebSocket closed.`)));
|
||||
ws.addEventListener("error", () => this.finishClose(new Error(`${this.label} CDP WebSocket failed.`)));
|
||||
}
|
||||
|
||||
static connect(url: string, options: CdpClientOptions = {}): Promise<CdpClient> {
|
||||
const label = options.label?.trim() || "App";
|
||||
const timeoutMs = options.connectTimeoutMs ?? 5_000;
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore close failures during timeout cleanup.
|
||||
}
|
||||
reject(new Error(`Timed out connecting to ${label} CDP WebSocket.`));
|
||||
}, timeoutMs);
|
||||
ws.addEventListener("open", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(new CdpClient(ws, label));
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`Failed to connect to ${label} CDP WebSocket.`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.ws.readyState === 0 || this.ws.readyState === 1) {
|
||||
this.ws.close();
|
||||
}
|
||||
this.finishClose(new Error(`${this.label} CDP WebSocket closed.`));
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): () => void {
|
||||
const handlers = this.handlers.get(method) || [];
|
||||
handlers.push(handler);
|
||||
this.handlers.set(method, handlers);
|
||||
return () => {
|
||||
const current = this.handlers.get(method);
|
||||
if (!current) return;
|
||||
const next = current.filter((item) => item !== handler);
|
||||
if (next.length) this.handlers.set(method, next);
|
||||
else this.handlers.delete(method);
|
||||
};
|
||||
}
|
||||
|
||||
send(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
if (this.ws.readyState !== 1 || this.closedSettled) {
|
||||
return Promise.reject(new Error(`${this.label} CDP WebSocket is not open.`));
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload = params === undefined ? { id, method } : { id, method, params };
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { reject, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
waitForClose(): Promise<void> {
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
private handleMessage(data: unknown): void {
|
||||
let message: CdpMessage;
|
||||
try {
|
||||
message = JSON.parse(webSocketDataToString(data)) as CdpMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof message.id === "number") {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || `CDP command failed with code ${message.error.code || "unknown"}`));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (!message.method) return;
|
||||
for (const handler of this.handlers.get(message.method) || []) {
|
||||
try {
|
||||
handler(message.params);
|
||||
} catch {
|
||||
// A faulty event consumer must not take down the CDP transport.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finishClose(error: Error): void {
|
||||
if (this.closedSettled) return;
|
||||
this.closedSettled = true;
|
||||
for (const pending of this.pending.values()) pending.reject(error);
|
||||
this.pending.clear();
|
||||
this.closeResolve();
|
||||
}
|
||||
}
|
||||
|
||||
function webSocketDataToString(data: unknown): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
||||
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
||||
return String(data);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import { createServer, type AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { WebSocket } from "undici";
|
||||
import { CdpClient } from "@ccr/core/agents/cdp-client";
|
||||
|
||||
type ClaudeAppCdpLogger = Pick<Console, "info" | "warn">;
|
||||
|
||||
@@ -20,20 +20,6 @@ type DevToolsTarget = {
|
||||
webSocketDebuggerUrl?: string;
|
||||
};
|
||||
|
||||
type CdpError = {
|
||||
code?: number;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type CdpMessage = {
|
||||
error?: CdpError;
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
type FetchRequestPausedParams = {
|
||||
request?: {
|
||||
url?: string;
|
||||
@@ -99,7 +85,7 @@ async function forceOpenClaudeAppDesignViaCdp(options: {
|
||||
throw new Error(`Claude App CDP page target was not available on port ${options.cdpPort}.`);
|
||||
}
|
||||
|
||||
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
|
||||
const client = await CdpClient.connect(target.webSocketDebuggerUrl, { label: "Claude App" });
|
||||
try {
|
||||
client.on("Fetch.requestPaused", (params) => {
|
||||
void handleFetchRequestPaused(client, params as FetchRequestPausedParams, options.logger);
|
||||
@@ -323,130 +309,6 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
class CdpClient {
|
||||
private readonly handlers = new Map<string, Array<(params: unknown) => void>>();
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (value: unknown) => void;
|
||||
}>();
|
||||
|
||||
private constructor(private readonly ws: WebSocket) {
|
||||
ws.addEventListener("message", (event) => this.handleMessage(event.data));
|
||||
ws.addEventListener("close", () => this.rejectPending(new Error("CDP WebSocket closed.")));
|
||||
ws.addEventListener("error", () => this.rejectPending(new Error("CDP WebSocket failed.")));
|
||||
}
|
||||
|
||||
static connect(url: string): Promise<CdpClient> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore close failures during timeout cleanup.
|
||||
}
|
||||
reject(new Error("Timed out connecting to Claude App CDP WebSocket."));
|
||||
}, 5_000);
|
||||
ws.addEventListener("open", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(new CdpClient(ws));
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Failed to connect to Claude App CDP WebSocket."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.ws.readyState === 0 || this.ws.readyState === 1) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
const handlers = this.handlers.get(method) || [];
|
||||
handlers.push(handler);
|
||||
this.handlers.set(method, handlers);
|
||||
}
|
||||
|
||||
send(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
if (this.ws.readyState !== 1) {
|
||||
return Promise.reject(new Error("CDP WebSocket is not open."));
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload = params === undefined ? { id, method } : { id, method, params };
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { reject, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(data: unknown): void {
|
||||
let message: CdpMessage;
|
||||
try {
|
||||
message = JSON.parse(webSocketDataToString(data)) as CdpMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof message.id === "number") {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || `CDP command failed with code ${message.error.code || "unknown"}`));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.method) {
|
||||
for (const handler of this.handlers.get(message.method) || []) {
|
||||
handler(message.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(error: Error): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function webSocketDataToString(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return data.toString("utf8");
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
function nodeErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import { codexModelCatalogJson } from "@ccr/core/agents/codex/model-catalog";
|
||||
import { prepareCodexAppCdpUserDataDir } from "@ccr/core/agents/codex/media-preview-bridge";
|
||||
import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profiles/launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
|
||||
import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config";
|
||||
@@ -254,6 +255,7 @@ function launchCodexCompatibleAppProfile(
|
||||
const configFile = resolveCodexConfigFile(configDir, profile);
|
||||
const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile);
|
||||
const { modelCatalogFile, userDataDir } = refreshCodexCompatibleAppProfileFiles(configDir, profile, config);
|
||||
if (spec.kind === "codex") prepareCodexAppCdpUserDataDir(userDataDir);
|
||||
|
||||
const appEnv: Record<string, string> = {
|
||||
...plan.env,
|
||||
@@ -403,6 +405,7 @@ function bundledCodexCliPath(appExecutable: string, spec: CodexCompatibleAppSpec
|
||||
function codexElectronArgs(userDataDir: string): string[] {
|
||||
return [
|
||||
"--remote-debugging-port=0",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--remote-allow-origins=*",
|
||||
"--disable-renderer-backgrounding",
|
||||
@@ -411,6 +414,10 @@ function codexElectronArgs(userDataDir: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function codexElectronArgsForTest(userDataDir: string): string[] {
|
||||
return codexElectronArgs(userDataDir);
|
||||
}
|
||||
|
||||
function codexAppLaunchCommand(executable: string, userDataDir: string): { args: string[]; command: string; pidIsLauncher?: boolean } {
|
||||
return {
|
||||
command: executable,
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { CdpClient } from "@ccr/core/agents/cdp-client";
|
||||
import { MEDIA_ARTIFACT_PATH_PREFIX } from "@ccr/core/mcp/grok-media-config";
|
||||
|
||||
type CodexMediaPreviewLogger = Pick<Console, "info" | "warn">;
|
||||
|
||||
type CodexMediaPreviewBridgeOptions = {
|
||||
endpoint: string;
|
||||
logger?: CodexMediaPreviewLogger;
|
||||
profileId: string;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
||||
type DevToolsTarget = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
};
|
||||
|
||||
type RuntimeBindingCalledParams = {
|
||||
executionContextId?: number;
|
||||
name?: string;
|
||||
payload?: string;
|
||||
};
|
||||
|
||||
type MediaPreviewBindingRequest = {
|
||||
key?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
|
||||
type ValidatedArtifactUrl = {
|
||||
artifactId: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
type LoadedMediaArtifact = {
|
||||
bytes: Buffer;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
const codexDevToolsActivePortFile = "DevToolsActivePort";
|
||||
const codexMediaPreviewBinding = "__ccrMediaPreviewRequest";
|
||||
const codexMediaPreviewConnectTimeoutMs = 20_000;
|
||||
const codexMediaPreviewFetchTimeoutMs = 60_000;
|
||||
const codexMediaPreviewPollIntervalMs = 250;
|
||||
const codexMediaPreviewReconnectDelayMs = 1_000;
|
||||
const codexMediaPreviewChunkBytes = 384 * 1024;
|
||||
const codexMediaPreviewMaxImageBytes = 15 * 1024 * 1024;
|
||||
const codexMediaPreviewMaxVideoBytes = 25 * 1024 * 1024;
|
||||
const codexMediaPreviewMaxResidentBytes = 50 * 1024 * 1024;
|
||||
const codexMediaPreviewMaxResidentVideos = 2;
|
||||
|
||||
export function shouldEnableCodexMediaPreviewBridge(mediaToolsEnabled: boolean): boolean {
|
||||
if (!mediaToolsEnabled) return false;
|
||||
const configured = process.env.CCR_CODEX_INLINE_VIDEO_PREVIEW?.trim().toLowerCase();
|
||||
return configured !== "0" && configured !== "false" && configured !== "off";
|
||||
}
|
||||
|
||||
export function prepareCodexAppCdpUserDataDir(userDataDir: string): void {
|
||||
rmSync(path.join(userDataDir, codexDevToolsActivePortFile), { force: true });
|
||||
}
|
||||
|
||||
export class CodexAppMediaPreviewBridge {
|
||||
readonly signature: string;
|
||||
private readonly activeDownloads = new Set<AbortController>();
|
||||
private client?: CdpClient;
|
||||
private readonly inFlight = new Set<string>();
|
||||
private readonly logger: CodexMediaPreviewLogger;
|
||||
private runPromise?: Promise<void>;
|
||||
private stopped = true;
|
||||
|
||||
constructor(private readonly options: CodexMediaPreviewBridgeOptions) {
|
||||
this.logger = options.logger || console;
|
||||
this.signature = `${path.resolve(options.userDataDir)}\u0000${new URL(options.endpoint).origin}`;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.stopped && this.runPromise) return;
|
||||
this.stopped = false;
|
||||
this.runPromise = this.run().finally(() => {
|
||||
this.runPromise = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const controller of this.activeDownloads) controller.abort();
|
||||
this.activeDownloads.clear();
|
||||
this.client?.close();
|
||||
this.client = undefined;
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
let announced = false;
|
||||
let warned = false;
|
||||
while (!this.stopped) {
|
||||
let client: CdpClient | undefined;
|
||||
try {
|
||||
const port = await waitForCodexDevToolsPort(this.options.userDataDir, codexMediaPreviewConnectTimeoutMs, () => this.stopped);
|
||||
if (this.stopped) return;
|
||||
const target = await waitForCodexPageTarget(port, codexMediaPreviewConnectTimeoutMs, () => this.stopped);
|
||||
if (!target.webSocketDebuggerUrl) throw new Error("Codex App page target has no debugger URL.");
|
||||
client = await CdpClient.connect(target.webSocketDebuggerUrl, { label: "Codex App" });
|
||||
if (this.stopped) {
|
||||
client.close();
|
||||
return;
|
||||
}
|
||||
this.client = client;
|
||||
await this.install(client);
|
||||
if (!announced) {
|
||||
announced = true;
|
||||
this.logger.info(`[profile] Enabled Codex App inline media previews for profile ${this.options.profileId}.`);
|
||||
}
|
||||
warned = false;
|
||||
await client.waitForClose();
|
||||
} catch (error) {
|
||||
if (!this.stopped && !warned) {
|
||||
warned = true;
|
||||
this.logger.warn(`[profile] Codex App inline media bridge is waiting to reconnect: ${redactBridgeError(error)}`);
|
||||
}
|
||||
} finally {
|
||||
if (this.client === client) this.client = undefined;
|
||||
client?.close();
|
||||
}
|
||||
if (!this.stopped) await sleep(codexMediaPreviewReconnectDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
private async install(client: CdpClient): Promise<void> {
|
||||
const script = codexMediaPreviewInjectionScript(this.options.endpoint);
|
||||
client.on("Runtime.bindingCalled", (params) => {
|
||||
void this.handleBinding(client, params as RuntimeBindingCalledParams);
|
||||
});
|
||||
await client.send("Page.enable");
|
||||
await client.send("Runtime.enable");
|
||||
try {
|
||||
await client.send("Runtime.removeBinding", { name: codexMediaPreviewBinding });
|
||||
} catch {
|
||||
// The first connection has no binding to remove.
|
||||
}
|
||||
await client.send("Runtime.addBinding", { name: codexMediaPreviewBinding });
|
||||
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: script });
|
||||
await client.send("Runtime.evaluate", {
|
||||
awaitPromise: false,
|
||||
expression: script
|
||||
});
|
||||
}
|
||||
|
||||
private async handleBinding(client: CdpClient, params: RuntimeBindingCalledParams): Promise<void> {
|
||||
if (this.stopped || params.name !== codexMediaPreviewBinding || typeof params.payload !== "string") return;
|
||||
let request: MediaPreviewBindingRequest;
|
||||
try {
|
||||
request = JSON.parse(params.payload) as MediaPreviewBindingRequest;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof request.key !== "string" || typeof request.url !== "string") return;
|
||||
let validated: ValidatedArtifactUrl;
|
||||
try {
|
||||
validated = validateCodexMediaArtifactUrl(request.url, this.options.endpoint);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (request.key !== validated.artifactId || this.inFlight.has(validated.artifactId)) return;
|
||||
this.inFlight.add(validated.artifactId);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), codexMediaPreviewFetchTimeoutMs);
|
||||
this.activeDownloads.add(controller);
|
||||
try {
|
||||
const artifact = await loadCodexMediaArtifact(validated, controller.signal);
|
||||
await this.sendArtifact(client, params.executionContextId, validated.artifactId, artifact);
|
||||
} catch (error) {
|
||||
await this.sendPageMessage(client, params.executionContextId, {
|
||||
key: validated.artifactId,
|
||||
type: "error"
|
||||
}).catch(() => undefined);
|
||||
if (!this.stopped) {
|
||||
this.logger.warn(`[profile] Codex App media artifact ${validated.artifactId} could not be previewed: ${redactBridgeError(error)}`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
this.activeDownloads.delete(controller);
|
||||
this.inFlight.delete(validated.artifactId);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendArtifact(
|
||||
client: CdpClient,
|
||||
executionContextId: number | undefined,
|
||||
key: string,
|
||||
artifact: LoadedMediaArtifact
|
||||
): Promise<void> {
|
||||
await this.sendPageMessage(client, executionContextId, {
|
||||
key,
|
||||
mimeType: artifact.mimeType,
|
||||
size: artifact.bytes.byteLength,
|
||||
type: "init"
|
||||
});
|
||||
for (let offset = 0; offset < artifact.bytes.byteLength; offset += codexMediaPreviewChunkBytes) {
|
||||
if (this.stopped) throw new Error("Media bridge stopped.");
|
||||
await this.sendPageMessage(client, executionContextId, {
|
||||
data: artifact.bytes.subarray(offset, offset + codexMediaPreviewChunkBytes).toString("base64"),
|
||||
key,
|
||||
type: "chunk"
|
||||
});
|
||||
}
|
||||
await this.sendPageMessage(client, executionContextId, { key, type: "complete" });
|
||||
}
|
||||
|
||||
private async sendPageMessage(
|
||||
client: CdpClient,
|
||||
executionContextId: number | undefined,
|
||||
message: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await client.send("Runtime.evaluate", {
|
||||
awaitPromise: false,
|
||||
...(typeof executionContextId === "number" ? { contextId: executionContextId } : {}),
|
||||
expression: `globalThis.__ccrMediaPreviewBridge?.receive(${JSON.stringify(message)})`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCodexDevToolsPort(userDataDir: string, timeoutMs: number, stopped: () => boolean): Promise<number> {
|
||||
const file = path.join(userDataDir, codexDevToolsActivePortFile);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (!stopped() && Date.now() < deadline) {
|
||||
try {
|
||||
const firstLine = readFileSync(file, "utf8").split(/\r?\n/, 1)[0]?.trim();
|
||||
const port = Number(firstLine);
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
|
||||
lastError = new Error("DevToolsActivePort did not contain a valid port.");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(codexMediaPreviewPollIntervalMs);
|
||||
}
|
||||
throw new Error(`Codex App DevTools port was not available${lastError ? `: ${redactBridgeError(lastError)}` : "."}`);
|
||||
}
|
||||
|
||||
async function waitForCodexPageTarget(port: number, timeoutMs: number, stopped: () => boolean): Promise<DevToolsTarget> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (!stopped() && Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(1_000)
|
||||
});
|
||||
if (!response.ok) throw new Error(`CDP target discovery returned HTTP ${response.status}.`);
|
||||
const targets = await response.json() as DevToolsTarget[];
|
||||
const pages = targets.filter((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
||||
const target = pages.find(isCodexAppPageTarget) || pages.find((entry) => (entry.url || "").startsWith("app://"));
|
||||
if (target) return target;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(codexMediaPreviewPollIntervalMs);
|
||||
}
|
||||
throw new Error(`Codex App CDP page target was not available${lastError ? `: ${redactBridgeError(lastError)}` : "."}`);
|
||||
}
|
||||
|
||||
function isCodexAppPageTarget(target: DevToolsTarget): boolean {
|
||||
if (target.type !== "page" || !target.webSocketDebuggerUrl) return false;
|
||||
const url = target.url || "";
|
||||
return url.startsWith("app://codex") || url.startsWith("app://chatgpt") || /\b(codex|chatgpt)\b/i.test(target.title || "");
|
||||
}
|
||||
|
||||
function validateCodexMediaArtifactUrl(value: string, endpoint: string): ValidatedArtifactUrl {
|
||||
const expected = new URL(endpoint);
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" || url.origin !== expected.origin) throw new Error("Artifact origin is not the configured CCR gateway.");
|
||||
if (url.username || url.password || url.hash) throw new Error("Artifact URL contains unsupported credentials or fragments.");
|
||||
if (!url.pathname.startsWith(MEDIA_ARTIFACT_PATH_PREFIX)) throw new Error("Artifact URL does not use the CCR media artifact path.");
|
||||
const encodedId = url.pathname.slice(MEDIA_ARTIFACT_PATH_PREFIX.length);
|
||||
if (!encodedId || encodedId.includes("/")) throw new Error("Artifact URL contains an invalid identifier.");
|
||||
const artifactId = decodeURIComponent(encodedId);
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(artifactId)) {
|
||||
throw new Error("Artifact URL contains an invalid identifier.");
|
||||
}
|
||||
const keys = [...url.searchParams.keys()];
|
||||
const token = url.searchParams.get("token") || "";
|
||||
if (keys.length !== 1 || keys[0] !== "token" || !/^[A-Za-z0-9_-]{32}$/.test(token)) {
|
||||
throw new Error("Artifact URL contains an invalid access token.");
|
||||
}
|
||||
return { artifactId, url };
|
||||
}
|
||||
|
||||
async function loadCodexMediaArtifact(validated: ValidatedArtifactUrl, signal: AbortSignal): Promise<LoadedMediaArtifact> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(validated.url, {
|
||||
headers: { accept: "image/*, video/*" },
|
||||
redirect: "error",
|
||||
signal
|
||||
});
|
||||
} catch {
|
||||
throw new Error("The CCR artifact request failed.");
|
||||
}
|
||||
if (!response.ok) throw new Error(`The CCR artifact endpoint returned HTTP ${response.status}.`);
|
||||
if (response.redirected) throw new Error("The CCR artifact endpoint attempted a redirect.");
|
||||
const declaredMimeType = (response.headers.get("content-type") || "").split(";", 1)[0].trim().toLowerCase();
|
||||
const declaredKind = mediaKind(declaredMimeType);
|
||||
if (!declaredKind) throw new Error("The CCR artifact endpoint returned a non-media content type.");
|
||||
const maxBytes = declaredKind === "video" ? codexMediaPreviewMaxVideoBytes : codexMediaPreviewMaxImageBytes;
|
||||
const declaredLength = Number(response.headers.get("content-length") || "0");
|
||||
if (declaredLength && (!Number.isSafeInteger(declaredLength) || declaredLength < 1 || declaredLength > maxBytes)) {
|
||||
throw new Error("The CCR media artifact exceeds the inline preview size limit.");
|
||||
}
|
||||
if (response.headers.get("content-encoding") && response.headers.get("content-encoding") !== "identity") {
|
||||
throw new Error("Compressed CCR media artifacts are not accepted for inline preview.");
|
||||
}
|
||||
if (!response.body) throw new Error("The CCR artifact response had no body.");
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
if (!part.value?.byteLength) continue;
|
||||
total += part.value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error("The CCR media artifact exceeds the inline preview size limit.");
|
||||
}
|
||||
chunks.push(Buffer.from(part.value));
|
||||
}
|
||||
if (!total) throw new Error("The CCR artifact response was empty.");
|
||||
if (declaredLength && total !== declaredLength) throw new Error("The CCR artifact response length did not match its headers.");
|
||||
const bytes = Buffer.concat(chunks, total);
|
||||
const detectedMimeType = detectMediaMimeType(bytes);
|
||||
if (!detectedMimeType || mediaKind(detectedMimeType) !== declaredKind) {
|
||||
throw new Error("The CCR artifact content did not match its declared media type.");
|
||||
}
|
||||
return { bytes, mimeType: detectedMimeType };
|
||||
}
|
||||
|
||||
function detectMediaMimeType(buffer: Buffer): string | undefined {
|
||||
if (buffer.byteLength >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
||||
if (buffer.byteLength >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
|
||||
if (buffer.byteLength >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
||||
if (buffer.byteLength >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp") {
|
||||
const brand = buffer.subarray(8, 12).toString("ascii");
|
||||
if (["avif", "avis", "mif1", "msf1"].includes(brand)) return "image/avif";
|
||||
return "video/mp4";
|
||||
}
|
||||
if (buffer.byteLength >= 4 && buffer.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) return "video/webm";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mediaKind(mimeType: string): "image" | "video" | undefined {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function codexMediaPreviewInjectionScript(endpoint: string): string {
|
||||
const expectedOrigin = new URL(endpoint).origin;
|
||||
return `(${codexMediaPreviewPageBootstrap.toString()})(${JSON.stringify({
|
||||
artifactPathPrefix: MEDIA_ARTIFACT_PATH_PREFIX,
|
||||
binding: codexMediaPreviewBinding,
|
||||
expectedOrigin,
|
||||
maxImageBytes: codexMediaPreviewMaxImageBytes,
|
||||
maxResidentBytes: codexMediaPreviewMaxResidentBytes,
|
||||
maxResidentVideos: codexMediaPreviewMaxResidentVideos,
|
||||
maxVideoBytes: codexMediaPreviewMaxVideoBytes,
|
||||
version: "2"
|
||||
})})`;
|
||||
}
|
||||
|
||||
function codexMediaPreviewPageBootstrap(config: {
|
||||
artifactPathPrefix: string;
|
||||
binding: string;
|
||||
expectedOrigin: string;
|
||||
maxImageBytes: number;
|
||||
maxResidentBytes: number;
|
||||
maxResidentVideos: number;
|
||||
maxVideoBytes: number;
|
||||
version: string;
|
||||
}): void {
|
||||
type PreviewAsset = { blobUrl: string; key: string; lastUsed: number; mimeType: string; size: number };
|
||||
type PreviewTransfer = { chunks: Uint8Array[]; mimeType: string; received: number; size: number };
|
||||
type HiddenState = { count: number; display: string; hidden: boolean };
|
||||
type PreviewScope = typeof globalThis & {
|
||||
__ccrMediaPreviewBridge?: { dispose: () => void; receive: (message: Record<string, unknown>) => void; scan: () => void; version: string };
|
||||
};
|
||||
const scope = globalThis as PreviewScope;
|
||||
const existing = scope.__ccrMediaPreviewBridge;
|
||||
if (existing?.version === config.version) {
|
||||
existing.scan();
|
||||
return;
|
||||
}
|
||||
existing?.dispose?.();
|
||||
|
||||
const assets = new Map<string, PreviewAsset>();
|
||||
const failed = new Set<string>();
|
||||
const hiddenStates = new Map<HTMLElement, HiddenState>();
|
||||
const pending = new Set<string>();
|
||||
const suppressed = new Set<string>();
|
||||
const targets = new Map<string, Set<HTMLElement>>();
|
||||
const transfers = new Map<string, PreviewTransfer>();
|
||||
const wrapperFallbacks = new Map<HTMLElement, HTMLElement[]>();
|
||||
let observer: MutationObserver | undefined;
|
||||
let scanTimer: number | undefined;
|
||||
|
||||
function parseArtifactUrl(raw: string): { key: string; url: string } | undefined {
|
||||
try {
|
||||
const normalized = raw.trim().replace(/[)\],.;]+$/g, "");
|
||||
const url = new URL(normalized);
|
||||
if (url.protocol !== "http:" || url.origin !== config.expectedOrigin || url.username || url.password || url.hash) return undefined;
|
||||
if (!url.pathname.startsWith(config.artifactPathPrefix)) return undefined;
|
||||
const encodedId = url.pathname.slice(config.artifactPathPrefix.length);
|
||||
if (!encodedId || encodedId.includes("/")) return undefined;
|
||||
const key = decodeURIComponent(encodedId);
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) return undefined;
|
||||
const queryKeys = [...url.searchParams.keys()];
|
||||
if (queryKeys.length !== 1 || queryKeys[0] !== "token" || !/^[A-Za-z0-9_-]{32}$/.test(url.searchParams.get("token") || "")) return undefined;
|
||||
return { key, url: url.toString() };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function artifactUrls(root: HTMLElement): Array<{ key: string; url: string }> {
|
||||
const values = new Set<string>();
|
||||
for (const element of root.querySelectorAll<HTMLElement>("a[href], [src]")) {
|
||||
const value = element.getAttribute("href") || element.getAttribute("src");
|
||||
if (value) values.add(value);
|
||||
}
|
||||
for (const match of root.textContent?.match(/https?:\/\/[^\s<>"'`]+/g) || []) values.add(match);
|
||||
const parsed = new Map<string, { key: string; url: string }>();
|
||||
for (const value of values) {
|
||||
const candidate = parseArtifactUrl(value);
|
||||
if (candidate) parsed.set(candidate.key, candidate);
|
||||
}
|
||||
return [...parsed.values()];
|
||||
}
|
||||
|
||||
function responseRoots(): HTMLElement[] {
|
||||
const roots = new Set<HTMLElement>();
|
||||
for (const root of document.querySelectorAll<HTMLElement>("[data-response-annotation-conversation], [data-message-author-role='assistant']")) roots.add(root);
|
||||
for (const button of document.querySelectorAll<HTMLElement>("button[aria-label='Open Web preview']")) {
|
||||
const root = button.closest<HTMLElement>("[data-response-annotation-conversation], [data-message-author-role='assistant']");
|
||||
if (root) roots.add(root);
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
function requestArtifact(candidate: { key: string; url: string }): void {
|
||||
if (assets.has(candidate.key) || failed.has(candidate.key) || pending.has(candidate.key) || suppressed.has(candidate.key)) return;
|
||||
const binding = (scope as unknown as Record<string, unknown>)[config.binding];
|
||||
if (typeof binding !== "function") return;
|
||||
pending.add(candidate.key);
|
||||
try {
|
||||
(binding as (payload: string) => void)(JSON.stringify({ key: candidate.key, url: candidate.url }));
|
||||
} catch {
|
||||
pending.delete(candidate.key);
|
||||
failed.add(candidate.key);
|
||||
}
|
||||
}
|
||||
|
||||
function scan(): void {
|
||||
for (const set of targets.values()) {
|
||||
for (const root of set) if (!root.isConnected) set.delete(root);
|
||||
}
|
||||
for (const root of responseRoots()) {
|
||||
for (const candidate of artifactUrls(root)) {
|
||||
const set = targets.get(candidate.key) || new Set<HTMLElement>();
|
||||
set.add(root);
|
||||
targets.set(candidate.key, set);
|
||||
const asset = assets.get(candidate.key);
|
||||
if (asset) attachAsset(root, asset);
|
||||
else requestArtifact(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleScan(): void {
|
||||
if (scanTimer !== undefined) clearTimeout(scanTimer);
|
||||
scanTimer = window.setTimeout(() => {
|
||||
scanTimer = undefined;
|
||||
scan();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function wrapperFor(root: HTMLElement, key: string): HTMLElement | undefined {
|
||||
return [...root.querySelectorAll<HTMLElement>("[data-ccr-inline-media-key]")].find((element) => element.dataset.ccrInlineMediaKey === key);
|
||||
}
|
||||
|
||||
function rawFallback(root: HTMLElement, key: string): HTMLElement | undefined {
|
||||
const candidates = [...root.querySelectorAll<HTMLElement>("pre, code, p")]
|
||||
.filter((element) => !element.closest("[data-ccr-inline-media-key]") && (element.textContent || "").includes(key))
|
||||
.filter((element) => element.matches("pre, code") || /<(?:img|video)\b/i.test(element.textContent || ""))
|
||||
.sort((left, right) => (left.textContent || "").length - (right.textContent || "").length);
|
||||
const selected = candidates[0];
|
||||
return selected?.matches("code") && selected.parentElement?.matches("pre") ? selected.parentElement : selected;
|
||||
}
|
||||
|
||||
function previewFallback(root: HTMLElement, key: string): HTMLElement | undefined {
|
||||
const buttons = [...root.querySelectorAll<HTMLElement>("button[aria-label='Open Web preview']")];
|
||||
let button = buttons.find((item) => {
|
||||
let current: HTMLElement | null = item;
|
||||
for (let depth = 0; current && current !== root && depth < 6; depth += 1, current = current.parentElement) {
|
||||
if ((current.textContent || "").includes(key) || [...current.querySelectorAll<HTMLElement>("a[href]")].some((link) => (link.getAttribute("href") || "").includes(key))) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!button && buttons.length === 1) button = buttons[0];
|
||||
if (!button) return undefined;
|
||||
let best = button;
|
||||
let current = button.parentElement;
|
||||
for (let depth = 0; current && current !== root && depth < 5; depth += 1, current = current.parentElement) {
|
||||
if (current.querySelector("[data-selected-text-overlay-target], [data-ccr-inline-media-key]")) break;
|
||||
const controls = current.querySelectorAll("button, input, textarea, select").length;
|
||||
const textLength = (current.textContent || "").trim().length;
|
||||
if (controls <= 4 && textLength <= 1_000) best = current;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function hideFallback(element: HTMLElement | undefined): void {
|
||||
if (!element) return;
|
||||
const state = hiddenStates.get(element);
|
||||
if (state) {
|
||||
state.count += 1;
|
||||
return;
|
||||
}
|
||||
hiddenStates.set(element, { count: 1, display: element.style.display, hidden: element.hidden });
|
||||
element.hidden = true;
|
||||
element.style.display = "none";
|
||||
}
|
||||
|
||||
function restoreFallback(element: HTMLElement): void {
|
||||
const state = hiddenStates.get(element);
|
||||
if (!state) return;
|
||||
state.count -= 1;
|
||||
if (state.count > 0) return;
|
||||
element.hidden = state.hidden;
|
||||
element.style.display = state.display;
|
||||
hiddenStates.delete(element);
|
||||
}
|
||||
|
||||
function removeWrapper(wrapper: HTMLElement): void {
|
||||
for (const fallback of wrapperFallbacks.get(wrapper) || []) restoreFallback(fallback);
|
||||
wrapperFallbacks.delete(wrapper);
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function attachAsset(root: HTMLElement, asset: PreviewAsset): void {
|
||||
if (!root.isConnected || wrapperFor(root, asset.key)) return;
|
||||
asset.lastUsed = Date.now();
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.dataset.ccrInlineMediaKey = asset.key;
|
||||
wrapper.style.marginTop = "12px";
|
||||
wrapper.style.maxWidth = "680px";
|
||||
wrapper.style.width = "100%";
|
||||
const media = asset.mimeType.startsWith("video/") ? document.createElement("video") : document.createElement("img");
|
||||
media.style.background = "#000";
|
||||
media.style.borderRadius = "12px";
|
||||
media.style.display = "block";
|
||||
media.style.height = "auto";
|
||||
media.style.maxHeight = "70vh";
|
||||
media.style.objectFit = "contain";
|
||||
media.style.width = "100%";
|
||||
if (media instanceof HTMLVideoElement) {
|
||||
media.controls = true;
|
||||
media.playsInline = true;
|
||||
media.preload = "metadata";
|
||||
media.src = asset.blobUrl;
|
||||
} else {
|
||||
media.alt = "CCR generated image";
|
||||
media.decoding = "async";
|
||||
media.src = asset.blobUrl;
|
||||
}
|
||||
wrapper.append(media);
|
||||
const preview = previewFallback(root, asset.key);
|
||||
const fallbacks = [rawFallback(root, asset.key), preview]
|
||||
.filter((item): item is HTMLElement => Boolean(item));
|
||||
const overlay = root.querySelector<HTMLElement>("[data-selected-text-overlay-target]");
|
||||
if (preview?.parentElement) preview.parentElement.insertBefore(wrapper, preview);
|
||||
else if (overlay?.parentElement) overlay.insertAdjacentElement("afterend", wrapper);
|
||||
else root.append(wrapper);
|
||||
|
||||
let settled = false;
|
||||
const finish = (ready: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (!ready) {
|
||||
removeWrapper(wrapper);
|
||||
failed.add(asset.key);
|
||||
return;
|
||||
}
|
||||
for (const fallback of fallbacks) hideFallback(fallback);
|
||||
wrapperFallbacks.set(wrapper, fallbacks);
|
||||
};
|
||||
const readyEvent = media instanceof HTMLVideoElement ? "canplay" : "load";
|
||||
media.addEventListener(readyEvent, () => finish(true), { once: true });
|
||||
media.addEventListener("error", () => finish(false), { once: true });
|
||||
const timeout = window.setTimeout(() => finish(false), 15_000);
|
||||
if (media instanceof HTMLImageElement && media.complete && media.naturalWidth > 0) finish(true);
|
||||
if (media instanceof HTMLVideoElement && media.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) finish(true);
|
||||
}
|
||||
|
||||
function evictAsset(key: string, suppress: boolean): void {
|
||||
const asset = assets.get(key);
|
||||
if (!asset) return;
|
||||
for (const wrapper of document.querySelectorAll<HTMLElement>("[data-ccr-inline-media-key]")) {
|
||||
if (wrapper.dataset.ccrInlineMediaKey === key) removeWrapper(wrapper);
|
||||
}
|
||||
URL.revokeObjectURL(asset.blobUrl);
|
||||
assets.delete(key);
|
||||
if (suppress) suppressed.add(key);
|
||||
}
|
||||
|
||||
function reserveResidentSpace(size: number, mimeType: string): boolean {
|
||||
const newVideo = mimeType.startsWith("video/") ? 1 : 0;
|
||||
while (true) {
|
||||
const currentBytes = [...assets.values()].reduce((sum, item) => sum + item.size, 0);
|
||||
const currentVideos = [...assets.values()].filter((item) => item.mimeType.startsWith("video/")).length;
|
||||
if (currentBytes + size <= config.maxResidentBytes && currentVideos + newVideo <= config.maxResidentVideos) return true;
|
||||
const oldest = [...assets.values()].sort((left, right) => left.lastUsed - right.lastUsed)[0];
|
||||
if (!oldest) return false;
|
||||
evictAsset(oldest.key, true);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function receive(message: Record<string, unknown>): void {
|
||||
const key = typeof message.key === "string" ? message.key : "";
|
||||
const type = typeof message.type === "string" ? message.type : "";
|
||||
if (!key) return;
|
||||
try {
|
||||
if (type === "error") {
|
||||
transfers.delete(key);
|
||||
pending.delete(key);
|
||||
failed.add(key);
|
||||
return;
|
||||
}
|
||||
if (type === "init") {
|
||||
const mimeType = typeof message.mimeType === "string" ? message.mimeType : "";
|
||||
const size = typeof message.size === "number" ? message.size : 0;
|
||||
const max = mimeType.startsWith("video/") ? config.maxVideoBytes : config.maxImageBytes;
|
||||
if ((!mimeType.startsWith("image/") && !mimeType.startsWith("video/")) || !Number.isSafeInteger(size) || size < 1 || size > max) throw new Error("invalid media transfer");
|
||||
transfers.set(key, { chunks: [], mimeType, received: 0, size });
|
||||
return;
|
||||
}
|
||||
const transfer = transfers.get(key);
|
||||
if (!transfer) throw new Error("missing media transfer");
|
||||
if (type === "chunk") {
|
||||
if (typeof message.data !== "string") throw new Error("invalid media chunk");
|
||||
const chunk = decodeBase64(message.data);
|
||||
transfer.received += chunk.byteLength;
|
||||
if (transfer.received > transfer.size) throw new Error("oversized media transfer");
|
||||
transfer.chunks.push(chunk);
|
||||
return;
|
||||
}
|
||||
if (type === "complete") {
|
||||
if (transfer.received !== transfer.size || !reserveResidentSpace(transfer.size, transfer.mimeType)) throw new Error("incomplete media transfer");
|
||||
const blobParts: BlobPart[] = transfer.chunks.map((chunk) => chunk.slice().buffer as ArrayBuffer);
|
||||
const blobUrl = URL.createObjectURL(new Blob(blobParts, { type: transfer.mimeType }));
|
||||
const asset = { blobUrl, key, lastUsed: Date.now(), mimeType: transfer.mimeType, size: transfer.size };
|
||||
transfers.delete(key);
|
||||
pending.delete(key);
|
||||
assets.set(key, asset);
|
||||
for (const root of targets.get(key) || []) attachAsset(root, asset);
|
||||
}
|
||||
} catch {
|
||||
transfers.delete(key);
|
||||
pending.delete(key);
|
||||
failed.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
observer?.disconnect();
|
||||
if (scanTimer !== undefined) clearTimeout(scanTimer);
|
||||
for (const wrapper of document.querySelectorAll<HTMLElement>("[data-ccr-inline-media-key]")) removeWrapper(wrapper);
|
||||
for (const asset of assets.values()) URL.revokeObjectURL(asset.blobUrl);
|
||||
assets.clear();
|
||||
transfers.clear();
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (!document.body) {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
return;
|
||||
}
|
||||
observer = new MutationObserver(scheduleScan);
|
||||
observer.observe(document.body, { childList: true, characterData: true, subtree: true });
|
||||
scan();
|
||||
}
|
||||
|
||||
scope.__ccrMediaPreviewBridge = { dispose, receive, scan, version: config.version };
|
||||
start();
|
||||
}
|
||||
|
||||
function redactBridgeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message
|
||||
.replace(/https?:\/\/\S+/gi, "[redacted-url]")
|
||||
.replace(/token=[A-Za-z0-9_-]+/gi, "token=[redacted]");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const codexMediaPreviewBridgeForTest = {
|
||||
injectionScript: codexMediaPreviewInjectionScript,
|
||||
loadArtifact: async (url: string, endpoint: string): Promise<LoadedMediaArtifact> => {
|
||||
const validated = validateCodexMediaArtifactUrl(url, endpoint);
|
||||
return await loadCodexMediaArtifact(validated, AbortSignal.timeout(5_000));
|
||||
},
|
||||
validateUrl: (url: string, endpoint: string): { artifactId: string; url: string } => {
|
||||
const validated = validateCodexMediaArtifactUrl(url, endpoint);
|
||||
return { artifactId: validated.artifactId, url: validated.url.toString() };
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
GatewayProviderCapability,
|
||||
GatewayProviderConfig,
|
||||
LocalAgentProviderCandidate,
|
||||
LocalAgentProviderImportResult,
|
||||
@@ -10,6 +11,11 @@ import type {
|
||||
ProviderAccountMappingConfig,
|
||||
ProviderModelMetadata
|
||||
} from "@ccr/core/contracts/app";
|
||||
import {
|
||||
GROK_API_DEFAULT_IMAGE_MODEL,
|
||||
GROK_API_DEFAULT_VIDEO_MODEL,
|
||||
GROK_API_MEDIA_BASE_URL
|
||||
} from "@ccr/core/contracts/app";
|
||||
import {
|
||||
bearerAuthPlugin,
|
||||
firstString,
|
||||
@@ -420,6 +426,33 @@ function importGrokProviderWithAuth(
|
||||
catalog.baseUrl,
|
||||
grokProviderAccountConfig()
|
||||
);
|
||||
const providerWithMedia = {
|
||||
...provider,
|
||||
capabilities: [
|
||||
{
|
||||
baseUrl: catalog.baseUrl,
|
||||
source: "preset" as const,
|
||||
type: "openai_responses" as const
|
||||
},
|
||||
{
|
||||
baseUrl: GROK_API_MEDIA_BASE_URL,
|
||||
endpoint: `${GROK_API_MEDIA_BASE_URL}/images/generations`,
|
||||
source: "preset" as const,
|
||||
type: "openai_image_generations" as const
|
||||
},
|
||||
{
|
||||
baseUrl: GROK_API_MEDIA_BASE_URL,
|
||||
endpoint: `${GROK_API_MEDIA_BASE_URL}/videos/generations`,
|
||||
source: "preset" as const,
|
||||
type: "openai_video_generations" as const
|
||||
}
|
||||
],
|
||||
models: uniqueStrings([
|
||||
...provider.models,
|
||||
GROK_API_DEFAULT_IMAGE_MODEL,
|
||||
GROK_API_DEFAULT_VIDEO_MODEL
|
||||
])
|
||||
};
|
||||
return {
|
||||
candidate: {
|
||||
...candidate,
|
||||
@@ -427,7 +460,7 @@ function importGrokProviderWithAuth(
|
||||
modelMetadata: catalog.modelMetadata,
|
||||
models: catalog.models
|
||||
},
|
||||
provider,
|
||||
provider: providerWithMedia,
|
||||
providerPlugins: [
|
||||
grokOauthPlugin("grok-cli-oauth", auth.accessToken ?? ""),
|
||||
grokOauthPlugin("grok-cli-oauth-internal", auth.accessToken ?? "", providerInternalNamePlaceholder)
|
||||
@@ -479,6 +512,41 @@ export function normalizeGrokProviderAccountConfig(provider: GatewayProviderConf
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGrokProviderMediaCapabilities(provider: GatewayProviderConfig): GatewayProviderConfig {
|
||||
if (!isLocalGrokProvider(provider)) {
|
||||
return provider;
|
||||
}
|
||||
const capabilities = [...(provider.capabilities ?? [])];
|
||||
const ensureCapability = (capability: GatewayProviderCapability): void => {
|
||||
if (!capabilities.some((item) => item.type === capability.type)) {
|
||||
capabilities.push(capability);
|
||||
}
|
||||
};
|
||||
const chatBaseUrl = providerBaseUrl(provider) || grokDefaultBaseUrl;
|
||||
ensureCapability({ baseUrl: chatBaseUrl, source: "preset", type: "openai_responses" });
|
||||
ensureCapability({
|
||||
baseUrl: GROK_API_MEDIA_BASE_URL,
|
||||
endpoint: `${GROK_API_MEDIA_BASE_URL}/images/generations`,
|
||||
source: "preset",
|
||||
type: "openai_image_generations"
|
||||
});
|
||||
ensureCapability({
|
||||
baseUrl: GROK_API_MEDIA_BASE_URL,
|
||||
endpoint: `${GROK_API_MEDIA_BASE_URL}/videos/generations`,
|
||||
source: "preset",
|
||||
type: "openai_video_generations"
|
||||
});
|
||||
return {
|
||||
...provider,
|
||||
capabilities,
|
||||
models: uniqueStrings([
|
||||
...provider.models,
|
||||
GROK_API_DEFAULT_IMAGE_MODEL,
|
||||
GROK_API_DEFAULT_VIDEO_MODEL
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalGrokProvider(provider: GatewayProviderConfig): boolean {
|
||||
if (providerApiKey(provider) !== localAgentProviderApiKey) {
|
||||
return false;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/con
|
||||
import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store";
|
||||
import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants";
|
||||
import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex";
|
||||
import { normalizeGrokProviderAccountConfig } from "@ccr/core/agents/local-providers/grok";
|
||||
import { normalizeGrokProviderAccountConfig, normalizeGrokProviderMediaCapabilities } from "@ccr/core/agents/local-providers/grok";
|
||||
import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-providers/opencode";
|
||||
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
|
||||
@@ -25,8 +25,10 @@ import type {
|
||||
GatewayPluginAppConfig,
|
||||
GatewayPluginProxyRouteConfig,
|
||||
GatewayProviderCapability,
|
||||
GatewayProviderCapabilityProtocol,
|
||||
GatewayProviderConfig,
|
||||
GatewayProviderProtocol,
|
||||
MediaToolsConfig,
|
||||
ObservabilityConfig,
|
||||
OverviewMetricKind,
|
||||
OverviewWidgetConfig,
|
||||
@@ -73,12 +75,13 @@ type LoadedBotGatewayConfig = Partial<Omit<BotGatewayRuntimeConfig, "handoff">>
|
||||
handoff?: Partial<BotGatewayRuntimeConfig["handoff"]>;
|
||||
};
|
||||
|
||||
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "gateway" | "observability" | "profile" | "proxy" | "toolHub">> & {
|
||||
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "gateway" | "mediaTools" | "observability" | "profile" | "proxy" | "toolHub">> & {
|
||||
Router?: Partial<RouterConfig>;
|
||||
agent?: Partial<GatewayAgentConfig>;
|
||||
botConfigs?: BotGatewaySavedConfig[];
|
||||
botGateway?: LoadedBotGatewayConfig;
|
||||
gateway?: Partial<AppConfig["gateway"]>;
|
||||
mediaTools?: Partial<MediaToolsConfig>;
|
||||
observability?: Partial<ObservabilityConfig>;
|
||||
profile?: LoadedProfileConfig;
|
||||
proxy?: Partial<ProxyRuntimeConfig>;
|
||||
@@ -260,6 +263,11 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
||||
host: gatewayConfig.host ?? host,
|
||||
port: gatewayConfig.port ?? port
|
||||
},
|
||||
mediaTools: {
|
||||
...DEFAULT_CONFIG.mediaTools,
|
||||
...(picked.mediaTools ?? {}),
|
||||
allowedInputRoots: picked.mediaTools?.allowedInputRoots ?? DEFAULT_CONFIG.mediaTools.allowedInputRoots
|
||||
},
|
||||
observability: {
|
||||
...DEFAULT_CONFIG.observability,
|
||||
...(picked.observability ?? {})
|
||||
@@ -574,8 +582,9 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
||||
if (Array.isArray((value as Record<string, unknown>).providerPlugins)) {
|
||||
config.providerPlugins = (value as Record<string, unknown>).providerPlugins as unknown[];
|
||||
}
|
||||
if (Array.isArray((value as Record<string, unknown>).virtualModelProfiles)) {
|
||||
config.virtualModelProfiles = (value as Record<string, unknown>).virtualModelProfiles as AppConfig["virtualModelProfiles"];
|
||||
const virtualModelProfiles = (value as Record<string, unknown>).virtualModelProfiles;
|
||||
if (Array.isArray(virtualModelProfiles)) {
|
||||
config.virtualModelProfiles = virtualModelProfiles.map(removeVirtualModelToolLoopLimits) as AppConfig["virtualModelProfiles"];
|
||||
}
|
||||
const plugins = parseGatewayPlugins((value as Record<string, unknown>).plugins ?? (value as Record<string, unknown>).gatewayPlugins);
|
||||
if (plugins) {
|
||||
@@ -635,6 +644,10 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
||||
if (observability) {
|
||||
config.observability = observability;
|
||||
}
|
||||
const mediaTools = parseMediaTools((value as Record<string, unknown>).mediaTools ?? (value as Record<string, unknown>).media_tools ?? (value as Record<string, unknown>).grokMedia ?? (value as Record<string, unknown>).grok_media);
|
||||
if (mediaTools) {
|
||||
config.mediaTools = mediaTools;
|
||||
}
|
||||
const toolHub = parseToolHub((value as Record<string, unknown>).toolHub ?? (value as Record<string, unknown>).tool_hub);
|
||||
if (toolHub) {
|
||||
config.toolHub = toolHub;
|
||||
@@ -685,6 +698,25 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function removeVirtualModelToolLoopLimits(value: unknown): unknown {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
!isObject(value.execution) ||
|
||||
(!("maxTurns" in value.execution) && !("maxToolCalls" in value.execution))
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
const { maxToolCalls: _maxToolCalls, maxTurns: _maxTurns, ...execution } = value.execution;
|
||||
return {
|
||||
...value,
|
||||
execution
|
||||
};
|
||||
}
|
||||
|
||||
export function virtualModelProfileFromRawForTest(value: unknown): unknown {
|
||||
return removeVirtualModelToolLoopLimits(value);
|
||||
}
|
||||
|
||||
function parseObservability(value: unknown): Partial<ObservabilityConfig> | undefined {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
@@ -756,6 +788,34 @@ function parseToolHub(value: unknown): Partial<ToolHubConfig> | undefined {
|
||||
return Object.keys(toolHub).length ? toolHub : undefined;
|
||||
}
|
||||
|
||||
function parseMediaTools(value: unknown): Partial<MediaToolsConfig> | undefined {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config: Partial<MediaToolsConfig> = {};
|
||||
if (typeof value.enabled === "boolean") config.enabled = value.enabled;
|
||||
const rawAllowedInputRoots = value.allowedInputRoots ?? value.allowed_input_roots;
|
||||
if (Array.isArray(rawAllowedInputRoots)) {
|
||||
config.allowedInputRoots = rawAllowedInputRoots
|
||||
.filter((item): item is string => typeof item === "string" && Boolean(item.trim()))
|
||||
.map((item) => item.trim());
|
||||
}
|
||||
const artifactTtlHours = readNumber(value.artifactTtlHours ?? value.artifact_ttl_hours);
|
||||
if (artifactTtlHours !== undefined) config.artifactTtlHours = clampNumber(artifactTtlHours, 1, 720);
|
||||
const jobTimeoutMs = readNumber(value.jobTimeoutMs ?? value.job_timeout_ms);
|
||||
if (jobTimeoutMs !== undefined) config.jobTimeoutMs = clampNumber(jobTimeoutMs, 30000, 3600000);
|
||||
const maxImageConcurrency = readNumber(value.maxImageConcurrency ?? value.max_image_concurrency);
|
||||
if (maxImageConcurrency !== undefined) config.maxImageConcurrency = clampNumber(maxImageConcurrency, 1, 8);
|
||||
const maxVideoConcurrency = readNumber(value.maxVideoConcurrency ?? value.max_video_concurrency);
|
||||
if (maxVideoConcurrency !== undefined) config.maxVideoConcurrency = clampNumber(maxVideoConcurrency, 1, 4);
|
||||
return Object.keys(config).length ? config : undefined;
|
||||
}
|
||||
|
||||
export function mediaToolsConfigFromRawForTest(value: unknown): Partial<MediaToolsConfig> | undefined {
|
||||
return parseMediaTools(value);
|
||||
}
|
||||
|
||||
function parseOverviewWidgets(value: unknown): OverviewWidgetConfig[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
@@ -1084,7 +1144,9 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
|
||||
type: readString(item.type)
|
||||
};
|
||||
return removeOpenCodeProviderAccountConfig(
|
||||
normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider))
|
||||
normalizeGrokProviderMediaCapabilities(
|
||||
normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider))
|
||||
)
|
||||
);
|
||||
})
|
||||
.filter((item): item is GatewayProviderConfig => Boolean(item));
|
||||
@@ -1325,7 +1387,7 @@ function parseProviderCapabilities(value: unknown): GatewayProviderCapability[]
|
||||
return capabilities.length > 0 ? capabilities : undefined;
|
||||
}
|
||||
|
||||
function parseProviderCapabilityProtocol(value: string | undefined): GatewayProviderProtocol | undefined {
|
||||
function parseProviderCapabilityProtocol(value: string | undefined): GatewayProviderCapabilityProtocol | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1336,6 +1398,12 @@ function parseProviderCapabilityProtocol(value: string | undefined): GatewayProv
|
||||
if (normalized === "openai_chat" || normalized === "openai_chat_completions") {
|
||||
return "openai_chat_completions";
|
||||
}
|
||||
if (normalized === "openai_image_generations" || normalized === "openai_images") {
|
||||
return "openai_image_generations";
|
||||
}
|
||||
if (normalized === "openai_video_generations" || normalized === "openai_videos") {
|
||||
return "openai_video_generations";
|
||||
}
|
||||
if (normalized === "anthropic" || normalized === "anthropic_messages") {
|
||||
return "anthropic_messages";
|
||||
}
|
||||
|
||||
@@ -99,6 +99,14 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
|
||||
host: "127.0.0.1",
|
||||
port: 3456
|
||||
},
|
||||
mediaTools: {
|
||||
allowedInputRoots: [],
|
||||
artifactTtlHours: 24,
|
||||
enabled: false,
|
||||
jobTimeoutMs: 600000,
|
||||
maxImageConcurrency: 2,
|
||||
maxVideoConcurrency: 1
|
||||
},
|
||||
launchAtLogin: false,
|
||||
observability: {
|
||||
agentAnalysis: false,
|
||||
|
||||
@@ -110,6 +110,39 @@ export type AppUpdateStatus = {
|
||||
export const BUILTIN_FUSION_TOOL_SERVER_NAME = "ccr-fusion-builtins";
|
||||
export const BUILTIN_FUSION_VISION_TOOL_NAME = "vision_understand";
|
||||
export const BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME = "web_search";
|
||||
export const BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME = "image_generation";
|
||||
export const BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME = "video_generation";
|
||||
export const GROK_API_MEDIA_BASE_URL = "https://api.x.ai/v1";
|
||||
export const GROK_API_DEFAULT_IMAGE_MODEL = "grok-imagine-image-quality";
|
||||
export const GROK_API_DEFAULT_VIDEO_MODEL = "grok-imagine-video";
|
||||
// Legacy sentinel retained only to migrate configs created before media execution
|
||||
// moved from the Grok CLI subprocess to the Grok API.
|
||||
export const GROK_CLI_MEDIA_MODEL_SELECTOR = "grok-cli";
|
||||
export const MEDIA_TOOLS_MCP_SERVER_NAME = "ccr-media-tools";
|
||||
export const MEDIA_IMAGE_GENERATE_TOOL_PREFIX = "image_generate";
|
||||
export const MEDIA_IMAGE_EDIT_TOOL_PREFIX = "image_edit";
|
||||
export const MEDIA_VIDEO_START_TOOL_PREFIX = "video_generate";
|
||||
export const MEDIA_JOB_GET_TOOL_PREFIX = "media_job_get";
|
||||
export const MEDIA_JOB_CANCEL_TOOL_PREFIX = "media_job_cancel";
|
||||
|
||||
// Legacy names are retained only so configs created by the first Grok-specific
|
||||
// implementation can be opened and migrated into the generic media tools.
|
||||
export const BUILTIN_FUSION_GROK_MEDIA_TOOL_NAME = "grok_media";
|
||||
export const GROK_MEDIA_MCP_SERVER_NAME = MEDIA_TOOLS_MCP_SERVER_NAME;
|
||||
export const GROK_MEDIA_IMAGE_GENERATE_TOOL_NAME = "grok_media_image_generate";
|
||||
export const GROK_MEDIA_IMAGE_EDIT_TOOL_NAME = "grok_media_image_edit";
|
||||
export const GROK_MEDIA_VIDEO_START_TOOL_NAME = "grok_media_video_start";
|
||||
export const GROK_MEDIA_JOB_GET_TOOL_NAME = "grok_media_job_get";
|
||||
export const GROK_MEDIA_JOB_CANCEL_TOOL_NAME = "grok_media_job_cancel";
|
||||
export const GROK_MEDIA_CAPABILITIES_TOOL_NAME = "grok_media_capabilities";
|
||||
export const GROK_MEDIA_FUSION_TOOL_NAMES = [
|
||||
GROK_MEDIA_IMAGE_GENERATE_TOOL_NAME,
|
||||
GROK_MEDIA_IMAGE_EDIT_TOOL_NAME,
|
||||
GROK_MEDIA_VIDEO_START_TOOL_NAME,
|
||||
GROK_MEDIA_JOB_GET_TOOL_NAME,
|
||||
GROK_MEDIA_JOB_CANCEL_TOOL_NAME,
|
||||
GROK_MEDIA_CAPABILITIES_TOOL_NAME
|
||||
] as const;
|
||||
|
||||
export type GatewayProviderProtocol =
|
||||
| "openai_responses"
|
||||
@@ -118,6 +151,12 @@ export type GatewayProviderProtocol =
|
||||
| "gemini_generate_content"
|
||||
| "gemini_interactions";
|
||||
|
||||
export type GatewayMediaProtocol =
|
||||
| "openai_image_generations"
|
||||
| "openai_video_generations";
|
||||
|
||||
export type GatewayProviderCapabilityProtocol = GatewayProviderProtocol | GatewayMediaProtocol;
|
||||
|
||||
export type GatewayProviderConfig = {
|
||||
account?: ProviderAccountConfig;
|
||||
api_base_url?: string;
|
||||
@@ -310,6 +349,7 @@ export type ProviderDeepLinkPayload = {
|
||||
account?: ProviderAccountConfig;
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
capabilities?: GatewayProviderCapability[];
|
||||
icon?: string;
|
||||
modelDescriptions?: Record<string, string>;
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
@@ -436,7 +476,7 @@ export type GatewayProviderCapability = {
|
||||
baseUrl: string;
|
||||
endpoint?: string;
|
||||
source?: "detected" | "preset";
|
||||
type: GatewayProviderProtocol;
|
||||
type: GatewayProviderCapabilityProtocol;
|
||||
};
|
||||
|
||||
export type GatewayProviderDetectedProvider = "new-api";
|
||||
@@ -448,15 +488,15 @@ export type GatewayProviderProbeRequest = {
|
||||
mode?: "connectivity" | "models" | "protocols";
|
||||
models?: string[];
|
||||
providerPlugins?: unknown[];
|
||||
protocols?: GatewayProviderProtocol[];
|
||||
protocols?: GatewayProviderCapabilityProtocol[];
|
||||
skipModelDiscovery?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayProviderProbeCandidate = {
|
||||
baseUrl: string;
|
||||
declaredProtocols?: GatewayProviderProtocol[];
|
||||
declaredProtocols?: GatewayProviderCapabilityProtocol[];
|
||||
label?: string;
|
||||
protocols: GatewayProviderProtocol[];
|
||||
protocols: GatewayProviderCapabilityProtocol[];
|
||||
source: "custom" | "preset";
|
||||
};
|
||||
|
||||
@@ -467,7 +507,7 @@ export type GatewayProviderProbeCandidatesRequest = {
|
||||
mode?: "connectivity" | "models" | "protocols";
|
||||
models?: string[];
|
||||
providerPlugins?: unknown[];
|
||||
protocols?: GatewayProviderProtocol[];
|
||||
protocols?: GatewayProviderCapabilityProtocol[];
|
||||
};
|
||||
|
||||
export type ProviderIconDetectionRequest = {
|
||||
@@ -487,7 +527,7 @@ export type GatewayProviderProbeProtocolResult = {
|
||||
detectedProvider?: GatewayProviderDetectedProvider;
|
||||
endpoint: string;
|
||||
message: string;
|
||||
protocol: GatewayProviderProtocol;
|
||||
protocol: GatewayProviderCapabilityProtocol;
|
||||
status?: number;
|
||||
supported: boolean;
|
||||
};
|
||||
@@ -523,7 +563,7 @@ export type GatewayProviderConnectivityCheckRequest = {
|
||||
forceRefresh?: boolean;
|
||||
models: string[];
|
||||
providerPlugins?: unknown[];
|
||||
protocols?: GatewayProviderProtocol[];
|
||||
protocols?: GatewayProviderCapabilityProtocol[];
|
||||
};
|
||||
|
||||
export type GatewayProviderConnectivityCheckReport = {
|
||||
@@ -759,6 +799,15 @@ export type ToolHubConfig = {
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
|
||||
export type MediaToolsConfig = {
|
||||
allowedInputRoots: string[];
|
||||
artifactTtlHours: number;
|
||||
enabled: boolean;
|
||||
jobTimeoutMs: number;
|
||||
maxImageConcurrency: number;
|
||||
maxVideoConcurrency: number;
|
||||
};
|
||||
|
||||
export const CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY";
|
||||
export const CLAUDE_CODE_DEFAULT_ENV: Record<string, string> = {
|
||||
[CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV]: "1"
|
||||
@@ -804,8 +853,6 @@ export type VirtualModelExecutionConfig = {
|
||||
clientToolsPolicy: "allow" | "deny";
|
||||
matchMultimodal?: boolean;
|
||||
matchWebSearch?: boolean;
|
||||
maxToolCalls: number;
|
||||
maxTurns: number;
|
||||
mode: VirtualModelExecutionMode;
|
||||
streamMode: "buffered" | "optimistic";
|
||||
};
|
||||
@@ -844,6 +891,16 @@ export type VirtualModelFusionWebSearchConfig = {
|
||||
toolName?: string;
|
||||
};
|
||||
|
||||
export type VirtualModelFusionMediaConfig = {
|
||||
imageEditToolName?: string;
|
||||
imageGenerateToolName?: string;
|
||||
imageModelSelector?: string;
|
||||
jobCancelToolName?: string;
|
||||
jobGetToolName?: string;
|
||||
videoModelSelector?: string;
|
||||
videoStartToolName?: string;
|
||||
};
|
||||
|
||||
export type VirtualModelFusionCustomToolConfig = {
|
||||
env?: Record<string, string>;
|
||||
mcpServerName?: string;
|
||||
@@ -1530,6 +1587,7 @@ export type AppConfig = {
|
||||
botConfigs: BotGatewaySavedConfig[];
|
||||
botGateway: BotGatewayRuntimeConfig;
|
||||
gateway: GatewayRuntimeConfig;
|
||||
mediaTools: MediaToolsConfig;
|
||||
launchAtLogin: boolean;
|
||||
observability: ObservabilityConfig;
|
||||
preferredProvider: string;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { closeServer, formatError } from "@ccr/core/gateway/http/io";
|
||||
import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync";
|
||||
import { assertLoopbackCoreHost, endpoint, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared";
|
||||
import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared";
|
||||
import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline";
|
||||
import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler";
|
||||
@@ -23,6 +24,8 @@ import { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime";
|
||||
import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context";
|
||||
import { compileRouterConfig } from "@ccr/core/routing/config-compiler";
|
||||
import { normalizeRouteScriptResult, scriptResultPreview } from "@ccr/core/routing/route-script-result";
|
||||
import { mediaService } from "@ccr/core/media/service";
|
||||
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
|
||||
|
||||
|
||||
class GatewayService {
|
||||
@@ -107,13 +110,19 @@ class GatewayService {
|
||||
};
|
||||
|
||||
try {
|
||||
mediaService.start(config, mediaToolsGatewayEndpoint(config), {
|
||||
authHeader: coreGatewayAuthHeader,
|
||||
authToken: coreAuthToken,
|
||||
baseUrl: endpoint(config.gateway.coreHost, config.gateway.corePort)
|
||||
});
|
||||
await pluginService.start(config);
|
||||
const shouldRunServer = shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes();
|
||||
const shouldRunServer = shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes() || config.mediaTools.enabled;
|
||||
const shouldRunGateway = shouldRunGatewayRuntime(config);
|
||||
if (shouldRunGateway && !hasAvailableGatewayModels(config)) {
|
||||
throw new Error(NO_AVAILABLE_GATEWAY_MODELS_MESSAGE);
|
||||
}
|
||||
if (!shouldRunServer) {
|
||||
await mediaService.stop();
|
||||
await pluginService.stop();
|
||||
await backendService.stopAll();
|
||||
this.coreAuthToken = "";
|
||||
@@ -196,6 +205,7 @@ class GatewayService {
|
||||
}
|
||||
await this.rawTraceSynchronizer.stop();
|
||||
await this.routeScriptRuntime.close();
|
||||
await mediaService.stop();
|
||||
|
||||
await proxyService.stop(options.proxyRestoreTimeoutMs);
|
||||
await pluginService.stop();
|
||||
@@ -234,6 +244,11 @@ class GatewayService {
|
||||
});
|
||||
this.config = config;
|
||||
this.plugin = nextPlugin;
|
||||
mediaService.updateConfig(config, mediaToolsGatewayEndpoint(config), {
|
||||
authHeader: coreGatewayAuthHeader,
|
||||
authToken: this.coreAuthToken,
|
||||
baseUrl: endpoint(config.gateway.coreHost, config.gateway.corePort)
|
||||
});
|
||||
proxyService.updateConfig(config);
|
||||
this.status = {
|
||||
...this.status,
|
||||
|
||||
@@ -290,6 +290,20 @@ async function resolveConfiguredRouteDecision(
|
||||
): Promise<ResolvedConfiguredRouteDecision> {
|
||||
const requestedModel = readString(request.body.model);
|
||||
const explicitModel = normalizeRouteSelector(requestedModel);
|
||||
const resolvedExplicitModel = compiled.modelRegistry.resolve(explicitModel) ?? compiled.modelRegistry.resolve(
|
||||
explicitModel
|
||||
? resolveClaudeAppGatewayRouteModel(explicitModel, config, claudeAppGatewayModelRouteOptions)
|
||||
: undefined
|
||||
);
|
||||
const explicitDecision: ConfiguredRouteDecision | undefined = resolvedExplicitModel
|
||||
? {
|
||||
fallback: compiled.fallback,
|
||||
model: resolvedExplicitModel,
|
||||
reason: "default",
|
||||
rewrites: [],
|
||||
source: "default"
|
||||
}
|
||||
: undefined;
|
||||
const builtInDecision = resolveBuiltInAgentRouteDecision(request, config, compiled.modelRegistry, compiled.fallback);
|
||||
const policies: Array<RoutePolicy<MutableRequestLike, ConfiguredRouteDecision>> = [
|
||||
{
|
||||
@@ -316,12 +330,20 @@ async function resolveConfiguredRouteDecision(
|
||||
...compiled.rules.map((rule): RoutePolicy<MutableRequestLike, ConfiguredRouteDecision> => ({
|
||||
evaluate: async (context) => {
|
||||
const decision = await resolveRouterRule(rule, context, compiled, runtime);
|
||||
return decision && builtInDecision
|
||||
? mergeConfiguredRouteDecisions(builtInDecision, decision)
|
||||
if (!decision || decision.rewrites.some(isBodyModelCompiledRewrite)) {
|
||||
return decision;
|
||||
}
|
||||
const baseDecision = explicitDecision ?? builtInDecision;
|
||||
return baseDecision
|
||||
? mergeConfiguredRouteDecisions(baseDecision, decision)
|
||||
: decision;
|
||||
},
|
||||
id: `rule:${rule.rule.id}`
|
||||
})),
|
||||
{
|
||||
evaluate: () => explicitDecision,
|
||||
id: "client-model"
|
||||
},
|
||||
{
|
||||
evaluate: () => builtInDecision,
|
||||
id: builtInDecision ? builtInAgentPolicyId(builtInDecision) : "builtin-agent"
|
||||
@@ -329,7 +351,7 @@ async function resolveConfiguredRouteDecision(
|
||||
{
|
||||
evaluate: () => ({
|
||||
fallback: compiled.fallback,
|
||||
model: compiled.modelRegistry.resolve(explicitModel),
|
||||
model: undefined,
|
||||
reason: "default",
|
||||
rewrites: [],
|
||||
source: "default"
|
||||
@@ -350,7 +372,7 @@ async function resolveConfiguredRouteDecision(
|
||||
}
|
||||
return {
|
||||
fallback: compiled.fallback,
|
||||
model: compiled.modelRegistry.resolve(explicitModel),
|
||||
model: resolvedExplicitModel,
|
||||
policyId: "default",
|
||||
reason: "default",
|
||||
rewrites: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { normalizeRouteSelector, providerRuntimeId } from "@ccr/core/routing/model-registry";
|
||||
import { isRecord, stringListValue, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { fusionBuiltinToolArtifacts, fusionToolFallbackMcpServer, normalizeFusionWebSearchProfileToolName, toolHubMcpServer, withCodexCompatibleVirtualModelProfiles, withFusionVirtualModelAliases, withFusionWebSearchToolInstructions } from "@ccr/core/mcp/fusion-config";
|
||||
import { mediaToolsMcpServer } from "@ccr/core/mcp/grok-media-config";
|
||||
import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery";
|
||||
import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology";
|
||||
import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync";
|
||||
@@ -20,6 +21,8 @@ import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "
|
||||
import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution";
|
||||
|
||||
const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer";
|
||||
export const unlimitedVirtualModelToolCalls = Number.MAX_SAFE_INTEGER;
|
||||
export const unlimitedVirtualModelToolTurns = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
|
||||
export async function compileCoreGatewayConfig(
|
||||
@@ -42,6 +45,7 @@ export async function compileCoreGatewayConfig(
|
||||
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
|
||||
]);
|
||||
const providerPlugins = await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins));
|
||||
const providerPluginsWithCapabilityAliases = withProviderCapabilityPluginAliases(providerPlugins, config.Providers);
|
||||
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
|
||||
const virtualModelProfiles = coreGatewayVirtualModelProfiles(config);
|
||||
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
|
||||
@@ -76,12 +80,15 @@ export async function compileCoreGatewayConfig(
|
||||
...(config.toolHub?.mcpServers ?? [])
|
||||
];
|
||||
const toolHubServer = toolHubMcpServer(config, externalMcpServers);
|
||||
const mediaMcpServer = mediaToolsMcpServer(config);
|
||||
const mcpServers = [
|
||||
...builtinToolArtifacts.mcpServers,
|
||||
...(mediaMcpServer ? [mediaMcpServer] : []),
|
||||
...(toolHubServer ? [toolHubServer] : externalMcpServers)
|
||||
];
|
||||
const fallbackMcpServer = fusionToolFallbackMcpServer(virtualModelProfiles, [
|
||||
...builtinToolArtifacts.mcpServers,
|
||||
...(mediaMcpServer ? [mediaMcpServer] : []),
|
||||
...externalMcpServers
|
||||
]);
|
||||
if (fallbackMcpServer) {
|
||||
@@ -110,7 +117,10 @@ export async function compileCoreGatewayConfig(
|
||||
billingWebhook: {
|
||||
enabled: false
|
||||
},
|
||||
bodyLimitBytes: 50 * 1024 * 1024,
|
||||
// Seven 25 MB reference images expand to roughly 234 MB when encoded as
|
||||
// JSON data URLs, so the internal gateway must accept the complete media
|
||||
// request even though normal chat payloads are much smaller.
|
||||
bodyLimitBytes: 256 * 1024 * 1024,
|
||||
host: config.gateway.coreHost,
|
||||
mcpGateway: {
|
||||
enabled: false
|
||||
@@ -130,13 +140,55 @@ export async function compileCoreGatewayConfig(
|
||||
mcpServers
|
||||
},
|
||||
rawTrace: buildRawTraceConfig(config, rawTraceSyncToken),
|
||||
providerPlugins,
|
||||
providerPlugins: providerPluginsWithCapabilityAliases,
|
||||
providers,
|
||||
virtualModelProfiles
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function withProviderCapabilityPluginAliases(
|
||||
providerPlugins: unknown[],
|
||||
providers: GatewayProviderConfig[]
|
||||
): unknown[] {
|
||||
const aliases: unknown[] = [];
|
||||
const explicitlyBoundProviderNames = new Set(
|
||||
providerPlugins
|
||||
.map((plugin) => isRecord(plugin) ? stringValue(plugin.providerName)?.toLowerCase() : undefined)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
);
|
||||
for (const plugin of providerPlugins) {
|
||||
if (!isRecord(plugin)) {
|
||||
continue;
|
||||
}
|
||||
const providerName = stringValue(plugin.providerName);
|
||||
const key = stringValue(plugin.key);
|
||||
if (!providerName || !key) {
|
||||
continue;
|
||||
}
|
||||
const provider = providers.find((item) => item.name.trim().toLowerCase() === providerName.toLowerCase());
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
for (const runtimeProvider of toCoreGatewayProviders(provider)) {
|
||||
const runtimeProviderName = runtimeProvider.name.trim().toLowerCase();
|
||||
if (
|
||||
runtimeProviderName === providerName.toLowerCase() ||
|
||||
explicitlyBoundProviderNames.has(runtimeProviderName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
aliases.push({
|
||||
...plugin,
|
||||
key: `${key}-runtime-${runtimeProvider.name}`,
|
||||
providerName: runtimeProvider.name
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...providerPlugins, ...aliases];
|
||||
}
|
||||
|
||||
|
||||
function providerPluginEnabled(plugin: unknown): boolean {
|
||||
return !isRecord(plugin) || plugin.enabled !== false;
|
||||
}
|
||||
@@ -158,10 +210,13 @@ export function coreGatewayUsageAttributionConfig(
|
||||
|
||||
|
||||
function coreGatewayVirtualModelProfiles(config: AppConfig): unknown[] {
|
||||
return normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
|
||||
const configuredProfiles = [
|
||||
...(config.virtualModelProfiles ?? []),
|
||||
...pluginService.getVirtualModelProfiles()
|
||||
])), config);
|
||||
];
|
||||
return normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(
|
||||
configuredProfiles
|
||||
)), config);
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +285,21 @@ function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppCo
|
||||
}
|
||||
|
||||
const profileAfterVision = nextProfile ?? profile;
|
||||
const profileAfterWebSearchToolName = normalizeFusionWebSearchProfileToolName(profileAfterVision) ?? profileAfterVision;
|
||||
const execution = isRecord(profileAfterVision.execution) ? profileAfterVision.execution : {};
|
||||
const profileWithoutToolLoopLimits = stringValue(execution.mode) === "decorate_only"
|
||||
? profileAfterVision
|
||||
: {
|
||||
...profileAfterVision,
|
||||
execution: {
|
||||
...execution,
|
||||
// Core Gateway requires numeric values and supplies finite defaults when
|
||||
// either field is missing. MAX_SAFE_INTEGER is the internal no-limit
|
||||
// sentinel for both dimensions; request timeout and cancellation remain.
|
||||
maxToolCalls: unlimitedVirtualModelToolCalls,
|
||||
maxTurns: unlimitedVirtualModelToolTurns
|
||||
}
|
||||
};
|
||||
const profileAfterWebSearchToolName = normalizeFusionWebSearchProfileToolName(profileWithoutToolLoopLimits) ?? profileWithoutToolLoopLimits;
|
||||
return withFusionWebSearchToolInstructions(profileAfterWebSearchToolName) ?? profileAfterWebSearchToolName;
|
||||
}
|
||||
|
||||
|
||||
@@ -511,7 +511,9 @@ export function shouldRunUnifiedServer(config: AppConfig): boolean {
|
||||
|
||||
|
||||
export function shouldRunGatewayRuntime(config: AppConfig): boolean {
|
||||
return config.gateway.enabled || (config.proxy.enabled && config.proxy.mode === "gateway");
|
||||
return config.gateway.enabled ||
|
||||
config.mediaTools.enabled ||
|
||||
(config.proxy.enabled && config.proxy.mode === "gateway");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ export function prepareClaudeAppDiscoveredModelRequest(
|
||||
if (!routedModel || routedModel.toLowerCase() === normalizedModel.toLowerCase()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
body: serializeJsonBodyWithModel(parsedBody, routedModel),
|
||||
diagnostic: `${model}->${routedModel}`,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
|
||||
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp";
|
||||
import { LEGACY_GROK_MEDIA_ARTIFACT_PATH_PREFIX, MEDIA_ARTIFACT_PATH_PREFIX, handleMediaArtifactRequest, handleMediaToolsMcpRequest } from "@ccr/core/mcp/grok-media-mcp";
|
||||
import { LEGACY_GROK_MEDIA_MCP_PATH, MEDIA_TOOLS_MCP_PATH } from "@ccr/core/mcp/grok-media-config";
|
||||
import { BROWSER_AUTOMATION_MCP_PATH, browserAutomationMcpEnabled } from "@ccr/core/mcp/toolhub-config";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
@@ -47,7 +49,8 @@ export class GatewayHttpRequestHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = request.url ? new URL(request.url, this.status.endpoint || "http://127.0.0.1").pathname : "/";
|
||||
const requestUrl = new URL(request.url ?? "/", this.status.endpoint || "http://127.0.0.1");
|
||||
const path = requestUrl.pathname;
|
||||
if (path === billingUsageSyncPath) {
|
||||
await this.handleBillingUsageSync(request, response);
|
||||
return;
|
||||
@@ -101,6 +104,22 @@ export class GatewayHttpRequestHandler {
|
||||
await this.browserAutomationMcpIntegration.handleBrowserAutomationMcpRequest(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if ([MEDIA_TOOLS_MCP_PATH, LEGACY_GROK_MEDIA_MCP_PATH].some((mcpPath) => path === mcpPath || path === `${mcpPath}/`)) {
|
||||
if (!this.config.mediaTools.enabled) {
|
||||
sendJson(response, 404, { error: { message: "CCR Media Tools MCP is disabled." } });
|
||||
return;
|
||||
}
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) return;
|
||||
await handleMediaToolsMcpRequest(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.startsWith(MEDIA_ARTIFACT_PATH_PREFIX) || path.startsWith(LEGACY_GROK_MEDIA_ARTIFACT_PATH_PREFIX)) {
|
||||
handleMediaArtifactRequest(request, response, requestUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNetworkCaptureMcpPath(path)) {
|
||||
if (!this.config.proxy.captureNetwork) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import type { ApiKeyConfig, GatewayMcpServerConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelFusionWebSearchProvider } from "@ccr/core/contracts/app";
|
||||
import type { ApiKeyConfig, GatewayMcpServerConfig, GatewayProviderCapabilityProtocol, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelFusionWebSearchProvider } from "@ccr/core/contracts/app";
|
||||
import type { ClaudeAppGatewayModelRouteOptions } from "@ccr/core/agents/claude-app/gateway-routes";
|
||||
import type { RouteModelRef } from "@ccr/core/routing/contracts";
|
||||
import { findModelCatalogEntry } from "@ccr/core/gateway/model-catalog";
|
||||
@@ -17,7 +17,7 @@ export type CoreGatewayProvider = {
|
||||
extraHeaders?: unknown;
|
||||
models: string[];
|
||||
name: string;
|
||||
type: GatewayProviderProtocol;
|
||||
type: GatewayProviderCapabilityProtocol;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export function shouldRestartGatewayForRuntimeConfigChange(previousConfig: AppCo
|
||||
JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) ||
|
||||
JSON.stringify(previousConfig.proxy.upstream) !== JSON.stringify(nextConfig.proxy.upstream) ||
|
||||
JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) ||
|
||||
JSON.stringify(previousConfig.mediaTools) !== JSON.stringify(nextConfig.mediaTools) ||
|
||||
JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) ||
|
||||
JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) ||
|
||||
JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) ||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { join as pathJoin } from "node:path";
|
||||
import type { AppConfig, GatewayMcpServerConfig, VirtualModelFusionVisionConfig, VirtualModelFusionWebSearchConfig, VirtualModelFusionWebSearchProvider } from "@ccr/core/contracts/app";
|
||||
import { BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "@ccr/core/contracts/app";
|
||||
import { BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, GROK_MEDIA_FUSION_TOOL_NAMES, MEDIA_TOOLS_MCP_SERVER_NAME, MEDIA_IMAGE_EDIT_TOOL_PREFIX, MEDIA_IMAGE_GENERATE_TOOL_PREFIX, MEDIA_JOB_CANCEL_TOOL_PREFIX, MEDIA_JOB_GET_TOOL_PREFIX, MEDIA_VIDEO_START_TOOL_PREFIX } from "@ccr/core/contracts/app";
|
||||
import { TOOL_HUB_MCP_SERVER_NAME, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config";
|
||||
import { isRecord, numberValue, stringListValue, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { defaultFusionWebSearchProvider, fusionModelProviderName } from "@ccr/core/gateway/internal/shared";
|
||||
@@ -233,7 +233,7 @@ export function fusionFallbackToolDefinitions(
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
if (backedToolNames.has(name)) {
|
||||
if (fusionToolIsBacked(name, backedToolNames)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -345,6 +345,9 @@ export function fusionToolNamesBackedByMcpServers(servers: unknown[]): Set<strin
|
||||
const serverName = stringValue(server.name);
|
||||
if (serverName) {
|
||||
names.add(serverName);
|
||||
if (serverName === MEDIA_TOOLS_MCP_SERVER_NAME) {
|
||||
for (const toolName of GROK_MEDIA_FUSION_TOOL_NAMES) names.add(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
const env = isRecord(server.env) ? server.env : undefined;
|
||||
@@ -357,6 +360,19 @@ export function fusionToolNamesBackedByMcpServers(servers: unknown[]): Set<strin
|
||||
}
|
||||
|
||||
|
||||
function fusionToolIsBacked(name: string, backedToolNames: Set<string>): boolean {
|
||||
if (backedToolNames.has(name)) return true;
|
||||
if (!backedToolNames.has(MEDIA_TOOLS_MCP_SERVER_NAME)) return false;
|
||||
return [
|
||||
MEDIA_IMAGE_EDIT_TOOL_PREFIX,
|
||||
MEDIA_IMAGE_GENERATE_TOOL_PREFIX,
|
||||
MEDIA_JOB_CANCEL_TOOL_PREFIX,
|
||||
MEDIA_JOB_GET_TOOL_PREFIX,
|
||||
MEDIA_VIDEO_START_TOOL_PREFIX
|
||||
].some((prefix) => name === prefix || name.startsWith(`${prefix}_`));
|
||||
}
|
||||
|
||||
|
||||
function uniqueMcpServerName(baseName: string, servers: unknown[]): string {
|
||||
const used = new Set(
|
||||
servers
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { join as pathJoin } from "node:path";
|
||||
import type { AppConfig, GatewayMcpServerConfig } from "@ccr/core/contracts/app";
|
||||
import { MEDIA_TOOLS_MCP_SERVER_NAME } from "@ccr/core/contracts/app";
|
||||
import { mediaMcpToolDefinition, mediaToolBindingsForConfig } from "@ccr/core/media/tools";
|
||||
|
||||
export const MEDIA_TOOLS_MCP_PATH = "/__ccr/media/mcp";
|
||||
export const LEGACY_GROK_MEDIA_MCP_PATH = "/__ccr/grok-media/mcp";
|
||||
export const MEDIA_ARTIFACT_PATH_PREFIX = "/__ccr/media/artifacts/";
|
||||
export const LEGACY_GROK_MEDIA_ARTIFACT_PATH_PREFIX = "/__ccr/grok-media/artifacts/";
|
||||
|
||||
export function mediaToolsMcpEnabled(config: AppConfig | undefined): boolean {
|
||||
return Boolean(config?.mediaTools?.enabled);
|
||||
}
|
||||
|
||||
export function mediaToolsMcpServer(
|
||||
config: AppConfig | undefined,
|
||||
options: { apiKey?: string } = {}
|
||||
): GatewayMcpServerConfig | undefined {
|
||||
if (!config || !mediaToolsMcpEnabled(config) || !hasGatewayEndpoint(config)) return undefined;
|
||||
const apiKey = options.apiKey || firstConfiguredApiKey(config);
|
||||
return {
|
||||
args: [bundledMediaToolsMcpEntryPath()],
|
||||
command: process.execPath,
|
||||
env: {
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
...(apiKey ? { CCR_MEDIA_MCP_API_KEY: apiKey } : {}),
|
||||
CCR_MEDIA_MCP_TOOLS_JSON: JSON.stringify(mediaToolBindingsForConfig(config).map(mediaMcpToolDefinition)),
|
||||
CCR_MEDIA_MCP_URL: `${mediaToolsGatewayEndpoint(config)}${MEDIA_TOOLS_MCP_PATH}`,
|
||||
CCR_MEDIA_MCP_REQUEST_TIMEOUT_MS: String(Math.min(3600000, Math.max(60000, config.mediaTools.jobTimeoutMs + 30000)))
|
||||
},
|
||||
name: MEDIA_TOOLS_MCP_SERVER_NAME,
|
||||
protocolVersion: "2024-11-05",
|
||||
requestTimeoutMs: Math.min(3600000, Math.max(60000, config.mediaTools.jobTimeoutMs + 30000)),
|
||||
startupTimeoutMs: 60000,
|
||||
stdioMessageMode: "content-length",
|
||||
transport: "stdio"
|
||||
};
|
||||
}
|
||||
|
||||
export function bundledMediaToolsMcpEntryPath(): string {
|
||||
return pathJoin(__dirname, "media-tools-proxy-mcp.js");
|
||||
}
|
||||
|
||||
|
||||
function firstConfiguredApiKey(config: AppConfig): string | undefined {
|
||||
return (Array.isArray(config.APIKEYS) ? config.APIKEYS : [])
|
||||
.find((apiKey) => apiKey.key.trim())?.key.trim() || stringValue(config.APIKEY);
|
||||
}
|
||||
|
||||
export function mediaToolsGatewayEndpoint(config: AppConfig): string {
|
||||
return `http://${formatHost(clientGatewayHost(config.gateway.host))}:${config.gateway.port}`;
|
||||
}
|
||||
|
||||
function hasGatewayEndpoint(config: AppConfig): boolean {
|
||||
const gateway = (config as Partial<AppConfig>).gateway;
|
||||
return Boolean(gateway && stringValue(gateway.host) && Number.isFinite(gateway.port));
|
||||
}
|
||||
|
||||
function formatHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function clientGatewayHost(host: string): string {
|
||||
const value = stringValue(host) ?? "127.0.0.1";
|
||||
if (value === "0.0.0.0") return "127.0.0.1";
|
||||
if (value === "::" || value === "[::]") return "::1";
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import packageJson from "../../package.json";
|
||||
import { createReadStream, statSync } from "node:fs";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import {
|
||||
MEDIA_TOOLS_MCP_SERVER_NAME
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
|
||||
import { mediaService } from "@ccr/core/media/service";
|
||||
import type { MediaService } from "@ccr/core/media/service";
|
||||
import { mediaMcpToolDefinition } from "@ccr/core/media/tools";
|
||||
import {
|
||||
LEGACY_GROK_MEDIA_ARTIFACT_PATH_PREFIX,
|
||||
MEDIA_ARTIFACT_PATH_PREFIX,
|
||||
MEDIA_TOOLS_MCP_PATH
|
||||
} from "@ccr/core/mcp/grok-media-config";
|
||||
|
||||
export { LEGACY_GROK_MEDIA_ARTIFACT_PATH_PREFIX, MEDIA_ARTIFACT_PATH_PREFIX } from "@ccr/core/mcp/grok-media-config";
|
||||
|
||||
type JsonPrimitive = boolean | null | number | string;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonRpcRequest = { id?: null | number | string; jsonrpc?: string; method?: string; params?: unknown };
|
||||
type JsonRpcResponse =
|
||||
| { id: null | number | string; jsonrpc: "2.0"; result: JsonValue }
|
||||
| { error: { code: number; message: string }; id: null | number | string; jsonrpc: "2.0" };
|
||||
|
||||
const protocolVersion = "2024-11-05";
|
||||
|
||||
export async function handleMediaToolsMcpRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
service: MediaService = mediaService
|
||||
): Promise<void> {
|
||||
response.setHeader("MCP-Protocol-Version", protocolVersion);
|
||||
if (!service.enabled()) {
|
||||
sendJson(response, 404, { error: { message: "Media tools MCP is disabled." } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET") {
|
||||
sendJson(response, 200, { endpoint: MEDIA_TOOLS_MCP_PATH, name: MEDIA_TOOLS_MCP_SERVER_NAME, protocol: "mcp", transport: "streamable-http" });
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
sendJson(response, 405, { error: { message: "MCP endpoint only supports GET and POST." } });
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse((await readRequestBody(request)).toString("utf8"));
|
||||
} catch (error) {
|
||||
sendJson(response, 400, jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`));
|
||||
return;
|
||||
}
|
||||
const requests = Array.isArray(payload) ? payload : [payload];
|
||||
const responses = await Promise.all(requests.map((item) => handleJsonRpcRequest(item, service)));
|
||||
const filtered = responses.filter((item): item is JsonRpcResponse => Boolean(item));
|
||||
if (!filtered.length) {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, Array.isArray(payload) ? filtered : filtered[0]);
|
||||
}
|
||||
|
||||
export function handleMediaArtifactRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
requestUrl: URL,
|
||||
service: MediaService = mediaService
|
||||
): void {
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
sendJson(response, 405, { error: { message: "Artifact endpoint only supports GET and HEAD." } });
|
||||
return;
|
||||
}
|
||||
const prefix = requestUrl.pathname.startsWith(MEDIA_ARTIFACT_PATH_PREFIX)
|
||||
? MEDIA_ARTIFACT_PATH_PREFIX
|
||||
: LEGACY_GROK_MEDIA_ARTIFACT_PATH_PREFIX;
|
||||
const id = decodeURIComponent(requestUrl.pathname.slice(prefix.length));
|
||||
const token = requestUrl.searchParams.get("token") ?? "";
|
||||
const result = service.resolveArtifact(id, token);
|
||||
if (result.state === "missing") {
|
||||
sendJson(response, 404, { error: { message: "Media artifact not found." } });
|
||||
return;
|
||||
}
|
||||
if (result.state === "expired") {
|
||||
sendJson(response, 410, { error: { message: "Media artifact has expired." } });
|
||||
return;
|
||||
}
|
||||
const artifact = result.artifact;
|
||||
const stats = statSync(artifact.localPath);
|
||||
const range = parseRange(request.headers.range, stats.size);
|
||||
if (request.headers.range && !range) {
|
||||
response.setHeader("content-range", `bytes */${stats.size}`);
|
||||
response.writeHead(416);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("accept-ranges", "bytes");
|
||||
response.setHeader("cache-control", "private, max-age=300");
|
||||
response.setHeader(
|
||||
"content-security-policy",
|
||||
"default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'"
|
||||
);
|
||||
response.setHeader("content-disposition", `inline; filename="${path.basename(artifact.fileName).replace(/["\\]/g, "_")}"`);
|
||||
response.setHeader("content-type", artifact.mimeType);
|
||||
response.setHeader("etag", `"${artifact.sha256}"`);
|
||||
response.setHeader("referrer-policy", "no-referrer");
|
||||
response.setHeader("x-content-type-options", "nosniff");
|
||||
if (range) {
|
||||
response.setHeader("content-length", String(range.end - range.start + 1));
|
||||
response.setHeader("content-range", `bytes ${range.start}-${range.end}/${stats.size}`);
|
||||
response.writeHead(206);
|
||||
} else {
|
||||
response.setHeader("content-length", String(stats.size));
|
||||
response.writeHead(200);
|
||||
}
|
||||
if (request.method === "HEAD") {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const stream = createReadStream(artifact.localPath, range ?? undefined);
|
||||
stream.once("error", () => response.destroy());
|
||||
stream.pipe(response);
|
||||
}
|
||||
|
||||
async function handleJsonRpcRequest(payload: unknown, service: MediaService): Promise<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(payload)) return jsonRpcError(null, -32600, "JSON-RPC request must be an object.");
|
||||
const request = payload as JsonRpcRequest;
|
||||
const id = request.id ?? null;
|
||||
if (request.id === undefined && request.method?.startsWith("notifications/")) return undefined;
|
||||
if (request.jsonrpc !== "2.0" || !request.method) return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request.");
|
||||
try {
|
||||
if (request.method === "initialize") {
|
||||
return jsonRpcResult(id, {
|
||||
capabilities: { tools: {} },
|
||||
protocolVersion,
|
||||
serverInfo: { name: "ccr-media-tools", title: "CCR Media Tools", version: packageJson.version }
|
||||
});
|
||||
}
|
||||
if (request.method === "ping") return jsonRpcResult(id, {});
|
||||
if (request.method === "tools/list") return jsonRpcResult(id, { tools: service.toolBindings().map(mediaMcpToolDefinition) as unknown as JsonValue });
|
||||
if (request.method === "tools/call") return jsonRpcResult(id, await callTool(request.params, service));
|
||||
return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`);
|
||||
} catch (error) {
|
||||
return jsonRpcError(id, -32603, formatError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function callTool(params: unknown, service: MediaService): Promise<JsonValue> {
|
||||
if (!isRecord(params) || typeof params.name !== "string") throw new Error("tools/call params must include a tool name.");
|
||||
const args = isRecord(params.arguments) ? params.arguments : {};
|
||||
const binding = service.bindingForTool(params.name);
|
||||
if (!binding) throw new Error(`Unknown media tool: ${params.name}`);
|
||||
let result: unknown;
|
||||
switch (binding.operation) {
|
||||
case "image-generate": result = await service.imageGenerate(args, binding.modelSelector); break;
|
||||
case "image-edit": result = await service.imageEdit(args, binding.modelSelector); break;
|
||||
case "video-generate": result = service.videoStart(args, binding.modelSelector); break;
|
||||
case "job-get": result = service.getJob(requiredJobId(args)); break;
|
||||
case "job-cancel": result = service.cancelJob(requiredJobId(args)); break;
|
||||
case "capabilities": result = service.capabilities(); break;
|
||||
}
|
||||
return {
|
||||
content: [{ text: JSON.stringify(result, null, 2), type: "text" }]
|
||||
} as unknown as JsonValue;
|
||||
}
|
||||
|
||||
function requiredJobId(args: Record<string, unknown>): string {
|
||||
if (typeof args.job_id !== "string" || !args.job_id.trim()) throw new Error("job_id is required.");
|
||||
return args.job_id.trim();
|
||||
}
|
||||
|
||||
function parseRange(value: string | undefined, size: number): { end: number; start: number } | undefined {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(value ?? "");
|
||||
if (!match) return undefined;
|
||||
let start = match[1] ? Number(match[1]) : 0;
|
||||
let end = match[2] ? Number(match[2]) : size - 1;
|
||||
if (!match[1] && match[2]) start = Math.max(0, size - Number(match[2]));
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) return undefined;
|
||||
return { end: Math.min(end, size - 1), start };
|
||||
}
|
||||
|
||||
function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse {
|
||||
return { id, jsonrpc: "2.0", result };
|
||||
}
|
||||
|
||||
function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse {
|
||||
return { error: { code, message }, id, jsonrpc: "2.0" };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
type JsonPrimitive = boolean | null | number | string;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonRpcId = null | number | string;
|
||||
type JsonRpcRequest = { id?: JsonRpcId; jsonrpc?: string; method?: string; params?: unknown };
|
||||
type JsonRpcResponse =
|
||||
| { id: JsonRpcId; jsonrpc: "2.0"; result: JsonValue }
|
||||
| { error: { code: number; message: string }; id: JsonRpcId; jsonrpc: "2.0" };
|
||||
type McpTool = { description: string; inputSchema: Record<string, unknown>; name: string };
|
||||
|
||||
const protocolVersion = "2024-11-05";
|
||||
const targetUrl = env("CCR_MEDIA_MCP_URL");
|
||||
const targetApiKey = env("CCR_MEDIA_MCP_API_KEY");
|
||||
const requestTimeoutMs = clampInteger(Number(env("CCR_MEDIA_MCP_REQUEST_TIMEOUT_MS")), 1_000, 3_600_000, 630_000);
|
||||
const tools = readTools();
|
||||
|
||||
let inputBuffer = Buffer.alloc(0);
|
||||
|
||||
if (!targetUrl) {
|
||||
process.stderr.write("CCR_MEDIA_MCP_URL is required.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
inputBuffer = Buffer.concat([inputBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
void drainInputBuffer().catch((error) => {
|
||||
writeJsonRpc(jsonRpcError(null, -32603, formatError(error)));
|
||||
});
|
||||
});
|
||||
process.stdin.resume();
|
||||
|
||||
async function drainInputBuffer(): Promise<void> {
|
||||
while (true) {
|
||||
const headerEnd = inputBuffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) return;
|
||||
const contentLength = readContentLength(inputBuffer.subarray(0, headerEnd).toString("utf8"));
|
||||
if (contentLength === undefined) {
|
||||
inputBuffer = inputBuffer.subarray(headerEnd + 4);
|
||||
writeJsonRpc(jsonRpcError(null, -32600, "Missing or invalid Content-Length header."));
|
||||
continue;
|
||||
}
|
||||
const messageStart = headerEnd + 4;
|
||||
const messageEnd = messageStart + contentLength;
|
||||
if (inputBuffer.length < messageEnd) return;
|
||||
const message = inputBuffer.subarray(messageStart, messageEnd).toString("utf8");
|
||||
inputBuffer = inputBuffer.subarray(messageEnd);
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(message) as unknown;
|
||||
} catch (error) {
|
||||
writeJsonRpc(jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`));
|
||||
continue;
|
||||
}
|
||||
const response = await handleJsonRpcRequest(payload);
|
||||
if (response) writeJsonRpc(response);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJsonRpcRequest(payload: unknown): Promise<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(payload)) return jsonRpcError(null, -32600, "JSON-RPC request must be an object.");
|
||||
const request = payload as JsonRpcRequest;
|
||||
const id = request.id ?? null;
|
||||
if (request.id === undefined && request.method?.startsWith("notifications/")) return undefined;
|
||||
if (request.jsonrpc !== "2.0" || !request.method) return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request.");
|
||||
|
||||
switch (request.method) {
|
||||
case "initialize":
|
||||
return jsonRpcResult(id, {
|
||||
capabilities: { tools: {} },
|
||||
protocolVersion,
|
||||
serverInfo: { name: "ccr-media-tools", title: "CCR Media Tools", version: "1.0.0" }
|
||||
});
|
||||
case "ping":
|
||||
return jsonRpcResult(id, {});
|
||||
case "tools/list":
|
||||
return jsonRpcResult(id, { tools: tools as unknown as JsonValue });
|
||||
case "tools/call":
|
||||
return forwardToolCall(request, id);
|
||||
default:
|
||||
return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToolCall(request: JsonRpcRequest, id: JsonRpcId): Promise<JsonRpcResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
"mcp-protocol-version": protocolVersion
|
||||
};
|
||||
if (targetApiKey) headers.authorization = `Bearer ${targetApiKey}`;
|
||||
const response = await fetch(targetUrl!, {
|
||||
body: JSON.stringify(request),
|
||||
headers,
|
||||
method: "POST",
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
return jsonRpcError(id, -32603, mediaEndpointError(response.status, text));
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return jsonRpcError(id, -32603, "CCR media endpoint returned an invalid JSON-RPC response.");
|
||||
}
|
||||
return isJsonRpcResponse(payload)
|
||||
? payload
|
||||
: jsonRpcError(id, -32603, "CCR media endpoint returned an invalid JSON-RPC response.");
|
||||
} catch (error) {
|
||||
return jsonRpcError(id, -32603, formatError(error));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function readTools(): McpTool[] {
|
||||
const raw = env("CCR_MEDIA_MCP_TOOLS_JSON");
|
||||
if (!raw) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: McpTool[] = [];
|
||||
for (const item of parsed) {
|
||||
if (!isRecord(item)) continue;
|
||||
const name = readString(item.name);
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
result.push({
|
||||
description: readString(item.description) ?? "CCR media generation tool.",
|
||||
inputSchema: isRecord(item.inputSchema) ? item.inputSchema : { type: "object", properties: {} },
|
||||
name
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readContentLength(headerText: string): number | undefined {
|
||||
const match = /(?:^|\r?\n)content-length\s*:\s*(\d+)\s*(?:\r?\n|$)/i.exec(headerText);
|
||||
if (!match) return undefined;
|
||||
const value = Number(match[1]);
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function mediaEndpointError(status: number, body: string): string {
|
||||
try {
|
||||
const payload = JSON.parse(body) as unknown;
|
||||
if (isRecord(payload) && isRecord(payload.error) && typeof payload.error.message === "string") {
|
||||
return `CCR media endpoint returned HTTP ${status}: ${payload.error.message}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to a bounded plain-text diagnostic.
|
||||
}
|
||||
return `CCR media endpoint returned HTTP ${status}${body.trim() ? `: ${body.trim().slice(0, 500)}` : "."}`;
|
||||
}
|
||||
|
||||
function jsonRpcResult(id: JsonRpcId, result: JsonValue): JsonRpcResponse {
|
||||
return { id, jsonrpc: "2.0", result };
|
||||
}
|
||||
|
||||
function jsonRpcError(id: JsonRpcId, code: number, message: string): JsonRpcResponse {
|
||||
return { error: { code, message }, id, jsonrpc: "2.0" };
|
||||
}
|
||||
|
||||
function writeJsonRpc(payload: JsonRpcResponse): void {
|
||||
const body = JSON.stringify(payload);
|
||||
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
|
||||
}
|
||||
|
||||
function isJsonRpcResponse(value: unknown): value is JsonRpcResponse {
|
||||
if (!isRecord(value) || value.jsonrpc !== "2.0" || !("id" in value)) return false;
|
||||
return "result" in value || (isRecord(value.error) && typeof value.error.message === "string" && typeof value.error.code === "number");
|
||||
}
|
||||
|
||||
function env(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function clampInteger(value: number, min: number, max: number, fallback: number): number {
|
||||
return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.name === "AbortError" ? `CCR media tool call timed out after ${requestTimeoutMs}ms.` : error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -1406,8 +1406,6 @@ class StdioMcpClient implements McpClient {
|
||||
}
|
||||
|
||||
const treeSitterToolName = "tree_sitter_collect_tool_references";
|
||||
const defaultMaxTurns = 3;
|
||||
const maxTurnsLimit = 6;
|
||||
const minSearchTimeoutMs = 8_000;
|
||||
const maxTurnTimeoutMs = 60_000;
|
||||
const minTurnRemainingMs = 3_500;
|
||||
@@ -1469,7 +1467,6 @@ class OpenAiToolHubSearchAgent {
|
||||
async search(input: {
|
||||
catalog: SearchCatalogItem[];
|
||||
code?: string;
|
||||
maxTurns?: number;
|
||||
query: string;
|
||||
timeoutMs?: number;
|
||||
topK?: number;
|
||||
@@ -1486,7 +1483,6 @@ class OpenAiToolHubSearchAgent {
|
||||
}
|
||||
|
||||
const topK = normalizeTopK(input.topK);
|
||||
const maxTurns = normalizeSearchMaxTurns(input.maxTurns);
|
||||
const timeoutMs = normalizeSearchTimeout(input.timeoutMs);
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
await waitForLocalResolverEndpoint(baseURL, apiKey, timeoutMs);
|
||||
@@ -1510,7 +1506,7 @@ class OpenAiToolHubSearchAgent {
|
||||
let referencedTokens: string[] = [];
|
||||
let latestResolvedFromAnalyzer: string[] = [];
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn += 1) {
|
||||
while (true) {
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
const minTurnTimeoutMs = didCallAnalyzer ? minFinalAnswerTurnTimeoutMs : minTurnRemainingMs;
|
||||
if (remainingMs <= minTurnTimeoutMs + timeoutHeadroomMs) {
|
||||
@@ -1607,7 +1603,7 @@ class OpenAiToolHubSearchAgent {
|
||||
: selectedToolNames.length === 0
|
||||
? "Your current answer resolved to zero valid catalog tools. Call the tree-sitter tool on a revised TypeScript workflow sketch before answering."
|
||||
: undefined;
|
||||
if (refinementFeedback && turn + 1 < maxTurns) {
|
||||
if (refinementFeedback) {
|
||||
messages.push(responseMessage);
|
||||
messages.push({ role: "user", content: refinementFeedback });
|
||||
continue;
|
||||
@@ -2185,10 +2181,6 @@ function normalizeTopK(value: unknown): number {
|
||||
return Math.min(Math.max(toPositiveIntOrDefault(value, defaultMaxTools), 1), 20);
|
||||
}
|
||||
|
||||
function normalizeSearchMaxTurns(value: unknown): number {
|
||||
return Math.min(Math.max(toPositiveIntOrDefault(value, defaultMaxTurns), 1), maxTurnsLimit);
|
||||
}
|
||||
|
||||
function normalizeSearchTimeout(value: unknown): number {
|
||||
return Math.max(toPositiveIntOrDefault(value, defaultRequestTimeoutMs), minSearchTimeoutMs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export type MediaOperation = "image-edit" | "image-generate" | "video-generate";
|
||||
export type MediaJobStatus = "canceled" | "failed" | "queued" | "running" | "succeeded";
|
||||
export type ResolvedMediaBackend = "gateway-media-api" | "provider-api";
|
||||
|
||||
export type MediaArtifact = {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
fileName: string;
|
||||
id: string;
|
||||
localPath: string;
|
||||
mimeType: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type MediaJobError = {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
export type MediaUsage = {
|
||||
costUsdTicks?: number;
|
||||
};
|
||||
|
||||
export type MediaJob = {
|
||||
artifact?: MediaArtifact;
|
||||
backend: ResolvedMediaBackend;
|
||||
createdAt: string;
|
||||
error?: MediaJobError;
|
||||
finishedAt?: string;
|
||||
id: string;
|
||||
idempotencyKeyHash?: string;
|
||||
modelSelector: string;
|
||||
operation: MediaOperation;
|
||||
remoteRequestId?: string;
|
||||
startedAt?: string;
|
||||
status: MediaJobStatus;
|
||||
updatedAt: string;
|
||||
usage?: MediaUsage;
|
||||
};
|
||||
|
||||
export type ImageGenerateRequest = {
|
||||
aspectRatio?: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type ImageEditRequest = ImageGenerateRequest & {
|
||||
images: string[];
|
||||
};
|
||||
|
||||
export type VideoGenerateRequest = {
|
||||
aspectRatio?: string;
|
||||
duration: 6 | 10;
|
||||
images: string[];
|
||||
prompt: string;
|
||||
resolution: "480p" | "720p";
|
||||
};
|
||||
|
||||
export type MediaRequest = ImageEditRequest | ImageGenerateRequest | VideoGenerateRequest;
|
||||
|
||||
export type MediaExecutionContext = {
|
||||
job: MediaJob;
|
||||
onRemoteRequestId: (requestId: string) => void;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type MediaExecutionResult = {
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
remoteUrl?: string;
|
||||
usage?: MediaUsage;
|
||||
};
|
||||
|
||||
export type PublicMediaArtifact = Omit<MediaArtifact, "accessToken" | "localPath"> & {
|
||||
localPath?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type PublicMediaJob = Omit<MediaJob, "artifact" | "idempotencyKeyHash"> & {
|
||||
artifact?: PublicMediaArtifact;
|
||||
};
|
||||
@@ -0,0 +1,391 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { closeSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { isIP } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { ImageEditRequest, ImageGenerateRequest, MediaExecutionContext, MediaExecutionResult, VideoGenerateRequest } from "@ccr/core/media/contracts";
|
||||
import { detectMediaType } from "@ccr/core/media/storage";
|
||||
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
|
||||
const maxApiArtifactBytes = 250 * 1024 * 1024;
|
||||
const maxArtifactRedirects = 5;
|
||||
|
||||
export type GatewayMediaTarget = {
|
||||
model: string;
|
||||
providerBaseUrl: string;
|
||||
providerName: string;
|
||||
providerSelector: string;
|
||||
};
|
||||
|
||||
export type GatewayMediaTransport = {
|
||||
authHeader?: string;
|
||||
authToken?: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export class GatewayMediaExecutor {
|
||||
constructor(
|
||||
private readonly target: GatewayMediaTarget,
|
||||
private readonly transport: GatewayMediaTransport
|
||||
) {}
|
||||
|
||||
async imageGenerate(request: ImageGenerateRequest, context: MediaExecutionContext): Promise<MediaExecutionResult> {
|
||||
const payload = await this.requestJson("images/generations", {
|
||||
aspect_ratio: request.aspectRatio,
|
||||
model: this.target.model,
|
||||
prompt: request.prompt,
|
||||
response_format: "url"
|
||||
}, context.signal, context.job.id);
|
||||
return parseImageResponse(payload);
|
||||
}
|
||||
|
||||
async imageEdit(request: ImageEditRequest, context: MediaExecutionContext): Promise<MediaExecutionResult> {
|
||||
const images = request.images.map((url) => ({ type: "image_url", url: localImageDataUrl(url) }));
|
||||
const payload = await this.requestJson("images/edits", {
|
||||
aspect_ratio: request.aspectRatio,
|
||||
...(images.length === 1 ? { image: images[0] } : { images }),
|
||||
model: this.target.model,
|
||||
prompt: request.prompt,
|
||||
response_format: "url"
|
||||
}, context.signal, context.job.id);
|
||||
return parseImageResponse(payload);
|
||||
}
|
||||
|
||||
async videoGenerate(request: VideoGenerateRequest, context: MediaExecutionContext): Promise<MediaExecutionResult> {
|
||||
const payload = await this.requestJson("videos/generations", {
|
||||
aspect_ratio: request.aspectRatio,
|
||||
duration: request.duration,
|
||||
image: request.images.length === 1 ? { url: localImageDataUrl(request.images[0]) } : undefined,
|
||||
model: this.target.model,
|
||||
prompt: request.prompt,
|
||||
reference_images: request.images.length > 1 ? request.images.map((image) => ({ url: localImageDataUrl(image) })) : undefined,
|
||||
resolution: request.resolution
|
||||
}, context.signal, context.job.id);
|
||||
const requestId = readString(payload, "request_id", "id");
|
||||
if (!requestId) throw mediaError("invalid_api_response", `${this.target.providerName} video API did not return a request id.`, false);
|
||||
context.onRemoteRequestId(requestId);
|
||||
return this.resumeVideo(requestId, context.signal);
|
||||
}
|
||||
|
||||
async resumeVideo(requestId: string, signal: AbortSignal): Promise<MediaExecutionResult> {
|
||||
while (true) {
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await this.getJson(`videos/${encodeURIComponent(requestId)}`, signal);
|
||||
} catch (error) {
|
||||
if (!isRetryableError(error) || signal.aborted) throw error;
|
||||
await delay(2000, undefined, { signal });
|
||||
continue;
|
||||
}
|
||||
const status = readString(payload, "status")?.toLowerCase();
|
||||
if (status === "done" || status === "completed" || status === "succeeded") {
|
||||
const url = readNestedString(payload, ["video", "url"]) ?? readString(payload, "url");
|
||||
if (!url) throw mediaError("invalid_api_response", `${this.target.providerName} video API completed without an artifact URL.`, false);
|
||||
return { fileName: `${requestId}.mp4`, remoteUrl: url, usage: readUsage(payload) };
|
||||
}
|
||||
if (status === "failed" || status === "expired" || status === "canceled" || status === "cancelled") {
|
||||
const message = readNestedString(payload, ["error", "message"]) ?? readString(payload, "message") ?? `Video generation ${status}.`;
|
||||
throw mediaError(`video_${status}`, message, status === "failed");
|
||||
}
|
||||
await delay(2000, undefined, { signal });
|
||||
}
|
||||
}
|
||||
|
||||
async download(result: MediaExecutionResult, signal: AbortSignal): Promise<MediaExecutionResult> {
|
||||
if (!result.remoteUrl) throw new Error("Remote media result has no URL.");
|
||||
let response: Response | undefined;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
const candidate = await fetchMediaArtifact(result.remoteUrl, this.target.providerBaseUrl, signal);
|
||||
if (candidate.ok || (candidate.status < 500 && candidate.status !== 408 && candidate.status !== 429)) {
|
||||
response = candidate;
|
||||
break;
|
||||
}
|
||||
lastError = mediaError("artifact_download_failed", `Failed to download generated artifact: HTTP ${candidate.status}.`, true);
|
||||
await candidate.body?.cancel();
|
||||
} catch (error) {
|
||||
if (isExplicitlyNonRetryableError(error)) throw error;
|
||||
lastError = error;
|
||||
}
|
||||
if (attempt < 3) await delay(attempt * 500, undefined, { signal });
|
||||
}
|
||||
if (!response) throw lastError ?? mediaError("artifact_download_failed", "Failed to download generated artifact.", true);
|
||||
if (!response.ok) throw mediaError("artifact_download_failed", `Failed to download generated artifact: HTTP ${response.status}.`, true);
|
||||
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||
if (declaredLength > maxApiArtifactBytes) throw mediaError("artifact_too_large", "Generated artifact exceeds the 250 MB limit.", false);
|
||||
if (!response.body) throw mediaError("artifact_download_failed", "Generated artifact response has no body.", true);
|
||||
const temporary = path.join(os.tmpdir(), `ccr-media-${randomUUID()}.download`);
|
||||
const file = openSync(temporary, "wx", 0o600);
|
||||
let size = 0;
|
||||
try {
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
const buffer = Buffer.from(chunk.value);
|
||||
size += buffer.byteLength;
|
||||
if (size > maxApiArtifactBytes) {
|
||||
await reader.cancel();
|
||||
throw mediaError("artifact_too_large", "Generated artifact exceeds the 250 MB limit.", false);
|
||||
}
|
||||
writeSync(file, buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
closeSync(file);
|
||||
rmSync(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
closeSync(file);
|
||||
return {
|
||||
contentType: response.headers.get("content-type") ?? undefined,
|
||||
fileName: result.fileName,
|
||||
filePath: temporary,
|
||||
usage: result.usage
|
||||
};
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
pathname: string,
|
||||
body: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
idempotencyKey?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetchWithSystemProxy(this.url(pathname), {
|
||||
body: JSON.stringify(stripUndefined(body)),
|
||||
headers: this.requestHeaders(true, idempotencyKey),
|
||||
method: "POST",
|
||||
signal
|
||||
});
|
||||
return readApiResponse(response);
|
||||
}
|
||||
|
||||
private async getJson(pathname: string, signal: AbortSignal): Promise<Record<string, unknown>> {
|
||||
const response = await fetchWithSystemProxy(this.url(pathname), {
|
||||
headers: this.requestHeaders(false),
|
||||
signal
|
||||
});
|
||||
return readApiResponse(response);
|
||||
}
|
||||
|
||||
private url(pathname: string): string {
|
||||
const gatewayRoot = this.transport.baseUrl.replace(/\/+$/g, "").replace(/\/v1$/i, "");
|
||||
return `${gatewayRoot}/v1/${pathname.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
private requestHeaders(jsonBody: boolean, idempotencyKey?: string): Record<string, string> {
|
||||
return {
|
||||
accept: "application/json",
|
||||
...(this.transport.authHeader && this.transport.authToken
|
||||
? { [this.transport.authHeader]: this.transport.authToken }
|
||||
: {}),
|
||||
...(jsonBody ? { "content-type": "application/json" } : {}),
|
||||
...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
|
||||
"x-target-model": this.target.model,
|
||||
"x-target-provider": this.target.providerSelector
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMediaArtifact(value: string, providerBaseUrl: string, signal: AbortSignal): Promise<Response> {
|
||||
let current = parseArtifactUrl(value);
|
||||
for (let redirectCount = 0; redirectCount <= maxArtifactRedirects; redirectCount += 1) {
|
||||
await assertArtifactUrlAllowed(current, providerBaseUrl);
|
||||
const response = await fetchWithSystemProxy(current, { redirect: "manual", signal });
|
||||
if (!isRedirectStatus(response.status)) return response;
|
||||
const location = response.headers.get("location");
|
||||
await response.body?.cancel();
|
||||
if (!location) {
|
||||
throw mediaError("artifact_redirect_invalid", "Generated artifact redirect did not include a location.", false);
|
||||
}
|
||||
if (redirectCount === maxArtifactRedirects) {
|
||||
throw mediaError("artifact_redirect_limit", `Generated artifact exceeded ${maxArtifactRedirects} redirects.`, false);
|
||||
}
|
||||
try {
|
||||
current = new URL(location, current);
|
||||
} catch {
|
||||
throw mediaError("artifact_redirect_invalid", "Generated artifact redirect contained an invalid URL.", false);
|
||||
}
|
||||
}
|
||||
throw mediaError("artifact_redirect_limit", `Generated artifact exceeded ${maxArtifactRedirects} redirects.`, false);
|
||||
}
|
||||
|
||||
function parseArtifactUrl(value: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw mediaError("artifact_url_invalid", "Generated artifact URL is invalid.", false);
|
||||
}
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.hash) {
|
||||
throw mediaError("artifact_url_invalid", "Generated artifact URL must be an HTTP(S) URL without credentials or fragments.", false);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function assertArtifactUrlAllowed(url: URL, providerBaseUrl: string): Promise<void> {
|
||||
const providerUrl = parseArtifactUrl(providerBaseUrl);
|
||||
const candidateHost = normalizeHostname(url.hostname);
|
||||
const providerHost = normalizeHostname(providerUrl.hostname);
|
||||
if (url.origin === providerUrl.origin) return;
|
||||
const candidateAddresses = await resolveHostAddresses(candidateHost);
|
||||
if (!candidateAddresses.some(isRestrictedIpAddress)) return;
|
||||
|
||||
if (url.protocol !== providerUrl.protocol || effectivePort(url) !== effectivePort(providerUrl)) {
|
||||
throw artifactUrlNotAllowedError();
|
||||
}
|
||||
const providerAddresses = await resolveHostAddresses(providerHost);
|
||||
if (candidateAddresses.every(isLoopbackIpAddress) && providerAddresses.some(isLoopbackIpAddress)) return;
|
||||
const providerAddressSet = new Set(providerAddresses.map(normalizeIpAddress));
|
||||
if (candidateAddresses.every((address) => providerAddressSet.has(normalizeIpAddress(address)))) return;
|
||||
throw artifactUrlNotAllowedError();
|
||||
}
|
||||
|
||||
function artifactUrlNotAllowedError(): Error & { code: string; retryable: boolean } {
|
||||
return mediaError(
|
||||
"artifact_url_not_allowed",
|
||||
"Generated artifact URL resolves to a private or non-public address outside the configured provider origin.",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function effectivePort(url: URL): string {
|
||||
return url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
}
|
||||
|
||||
async function resolveHostAddresses(hostname: string): Promise<string[]> {
|
||||
if (isIP(hostname)) return [normalizeIpAddress(hostname)];
|
||||
try {
|
||||
const addresses = await lookup(hostname, { all: true, verbatim: true });
|
||||
const unique = [...new Set(addresses.map((item) => normalizeIpAddress(item.address)).filter(Boolean))];
|
||||
if (unique.length) return unique;
|
||||
} catch {
|
||||
// Surface a bounded media error instead of letting an unchecked proxy-side
|
||||
// DNS resolution bypass the private-network policy.
|
||||
}
|
||||
throw mediaError("artifact_host_unresolvable", "Generated artifact host could not be resolved safely.", false);
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function normalizeIpAddress(value: string): string {
|
||||
return normalizeHostname(value).split("%", 1)[0];
|
||||
}
|
||||
|
||||
function isRestrictedIpAddress(value: string): boolean {
|
||||
const address = normalizeIpAddress(value);
|
||||
if (isIP(address) === 4) {
|
||||
const octets = address.split(".").map(Number);
|
||||
const [first, second] = octets;
|
||||
return first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 100 && second >= 64 && second <= 127) ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && (second === 0 || second === 168)) ||
|
||||
(first === 198 && (second === 18 || second === 19 || second === 51)) ||
|
||||
(first === 203 && second === 0) ||
|
||||
first >= 224;
|
||||
}
|
||||
if (isIP(address) === 6) {
|
||||
return address === "::" ||
|
||||
address === "::1" ||
|
||||
address.startsWith("::ffff:") ||
|
||||
/^(?:fc|fd)/.test(address) ||
|
||||
/^fe[89ab]/.test(address) ||
|
||||
address.startsWith("ff") ||
|
||||
address.startsWith("2001:db8:");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLoopbackIpAddress(value: string): boolean {
|
||||
const address = normalizeIpAddress(value);
|
||||
return address === "::1" || (isIP(address) === 4 && address.startsWith("127."));
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||
}
|
||||
|
||||
export function mediaError(code: string, message: string, retryable: boolean): Error & { code: string; retryable: boolean } {
|
||||
return Object.assign(new Error(message), { code, retryable });
|
||||
}
|
||||
|
||||
async function readApiResponse(response: Response): Promise<Record<string, unknown>> {
|
||||
const text = await response.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const record = isRecord(payload) ? payload : {};
|
||||
const message = sanitizeRemoteError(readNestedString(record, ["error", "message"]) ?? readString(record, "message") ?? `Media gateway request failed with HTTP ${response.status}.`);
|
||||
throw mediaError(`gateway_http_${response.status}`, message, response.status === 408 || response.status === 429 || response.status >= 500);
|
||||
}
|
||||
if (!isRecord(payload)) throw mediaError("invalid_api_response", "Media gateway returned a non-object response.", false);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function parseImageResponse(payload: Record<string, unknown>): MediaExecutionResult {
|
||||
const first = Array.isArray(payload.data) && isRecord(payload.data[0]) ? payload.data[0] : payload;
|
||||
const url = readString(first, "url");
|
||||
if (url) return { fileName: "generated-image", remoteUrl: url, usage: readUsage(payload) };
|
||||
const base64 = readString(first, "b64_json");
|
||||
if (base64) {
|
||||
const temporary = path.join(os.tmpdir(), `ccr-media-${randomUUID()}.image`);
|
||||
writeFileSync(temporary, Buffer.from(base64, "base64"), { mode: 0o600 });
|
||||
return { contentType: readString(first, "mime_type"), fileName: "generated-image", filePath: temporary, usage: readUsage(payload) };
|
||||
}
|
||||
throw mediaError("invalid_api_response", "Image API completed without an image URL or payload.", false);
|
||||
}
|
||||
|
||||
function localImageDataUrl(file: string): string {
|
||||
const type = detectMediaType(file).mimeType;
|
||||
if (!type?.startsWith("image/")) throw mediaError("invalid_input_media", `Input is not a supported image: ${file}`, false);
|
||||
return `data:${type};base64,${readFileSync(file).toString("base64")}`;
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "retryable" in error && error.retryable === true);
|
||||
}
|
||||
|
||||
function isExplicitlyNonRetryableError(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "retryable" in error && error.retryable === false);
|
||||
}
|
||||
|
||||
function readUsage(payload: Record<string, unknown>): { costUsdTicks?: number } | undefined {
|
||||
const usage = isRecord(payload.usage) ? payload.usage : undefined;
|
||||
const costUsdTicks = usage?.cost_in_usd_ticks;
|
||||
return typeof costUsdTicks === "number" && Number.isFinite(costUsdTicks) ? { costUsdTicks } : undefined;
|
||||
}
|
||||
|
||||
function sanitizeRemoteError(value: string): string {
|
||||
return value.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/[A-Za-z0-9_-]{40,}/g, "[redacted]").trim().slice(0, 2000);
|
||||
}
|
||||
|
||||
function stripUndefined(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) if (typeof record[key] === "string" && record[key].trim()) return record[key].trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNestedString(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
let value: unknown = record;
|
||||
for (const key of keys) value = isRecord(value) ? value[key] : undefined;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
GROK_API_DEFAULT_IMAGE_MODEL,
|
||||
GROK_API_DEFAULT_VIDEO_MODEL,
|
||||
GROK_CLI_MEDIA_MODEL_SELECTOR
|
||||
} from "@ccr/core/contracts/app";
|
||||
import type { GatewayProviderConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
const localAgentProviderApiKey = "ccr-local-agent-login";
|
||||
|
||||
export type GrokMediaKind = "image" | "video";
|
||||
|
||||
export type GrokMediaModelOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function grokMediaModelKind(model: string | undefined): GrokMediaKind | undefined {
|
||||
const id = model?.trim().split("/").pop()?.toLowerCase();
|
||||
if (id?.startsWith("grok-imagine-image")) return "image";
|
||||
if (id?.startsWith("grok-imagine-video")) return "video";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isImportedGrokAgentProvider(provider: GatewayProviderConfig): boolean {
|
||||
const apiKey = provider.apikey || provider.apiKey || provider.api_key || "";
|
||||
if (apiKey !== localAgentProviderApiKey) return false;
|
||||
const baseUrl = provider.baseurl || provider.baseUrl || provider.api_base_url || "";
|
||||
return /(?:^|\.)cli-chat-proxy\.grok\.com$/i.test(urlHost(baseUrl)) || /grok/i.test(provider.name ?? "");
|
||||
}
|
||||
|
||||
export function grokMediaModelsForProvider(provider: GatewayProviderConfig, kind: GrokMediaKind): string[] {
|
||||
if (!providerSupportsMediaKind(provider, kind)) {
|
||||
return [];
|
||||
}
|
||||
const configured = (provider.models ?? [])
|
||||
.map((model) => model.trim())
|
||||
.filter((model) => model && grokMediaModelKind(model) !== oppositeMediaKind(kind));
|
||||
const classified = configured.filter((model) => grokMediaModelKind(model) === kind);
|
||||
if (!isImportedGrokAgentProvider(provider)) {
|
||||
return uniqueStrings(classified.length > 0 ? classified : configured);
|
||||
}
|
||||
const fallback = kind === "image" ? GROK_API_DEFAULT_IMAGE_MODEL : GROK_API_DEFAULT_VIDEO_MODEL;
|
||||
return uniqueStrings([
|
||||
...classified,
|
||||
fallback
|
||||
]);
|
||||
}
|
||||
|
||||
export function providerSupportsMediaKind(provider: GatewayProviderConfig, kind: GrokMediaKind): boolean {
|
||||
const capability = kind === "image" ? "openai_image_generations" : "openai_video_generations";
|
||||
return isImportedGrokAgentProvider(provider) ||
|
||||
(provider.capabilities ?? []).some((item) => item.type === capability) ||
|
||||
(provider.models ?? []).some((model) => grokMediaModelKind(model) === kind);
|
||||
}
|
||||
|
||||
export function createGrokMediaModelOptions(
|
||||
providers: GatewayProviderConfig[],
|
||||
kind: GrokMediaKind
|
||||
): GrokMediaModelOption[] {
|
||||
return providers.flatMap((provider) => grokMediaModelsForProvider(provider, kind).map((model) => ({
|
||||
label: `${provider.name}/${mediaModelDisplayName(model)}`,
|
||||
value: `${provider.name}/${model}`
|
||||
})));
|
||||
}
|
||||
|
||||
export function defaultGrokMediaModelSelector(
|
||||
providers: GatewayProviderConfig[],
|
||||
kind: GrokMediaKind
|
||||
): string | undefined {
|
||||
return createGrokMediaModelOptions(providers, kind)[0]?.value;
|
||||
}
|
||||
|
||||
export function migrateLegacyGrokMediaModelSelector(
|
||||
providers: GatewayProviderConfig[],
|
||||
selector: string | undefined,
|
||||
kind: GrokMediaKind
|
||||
): string | undefined {
|
||||
const normalized = selector?.trim();
|
||||
if (normalized && normalized !== GROK_CLI_MEDIA_MODEL_SELECTOR) return normalized;
|
||||
return defaultGrokMediaModelSelector(providers, kind);
|
||||
}
|
||||
|
||||
function mediaModelDisplayName(model: string): string {
|
||||
const id = model.split("/").pop() ?? model;
|
||||
if (id === GROK_API_DEFAULT_IMAGE_MODEL) return "Grok Imagine Image Quality";
|
||||
if (id === GROK_API_DEFAULT_VIDEO_MODEL) return "Grok Imagine Video";
|
||||
return model;
|
||||
}
|
||||
|
||||
function oppositeMediaKind(kind: GrokMediaKind): GrokMediaKind {
|
||||
return kind === "image" ? "video" : "image";
|
||||
}
|
||||
|
||||
function urlHost(value: string): string {
|
||||
try {
|
||||
return new URL(value).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { existsSync, realpathSync, rmSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import type { AppConfig, GatewayMediaProtocol, MediaToolsConfig } from "@ccr/core/contracts/app";
|
||||
import type { ImageEditRequest, ImageGenerateRequest, MediaArtifact, MediaExecutionContext, MediaExecutionResult, MediaJob, MediaJobError, MediaOperation, MediaRequest, PublicMediaArtifact, PublicMediaJob, VideoGenerateRequest } from "@ccr/core/media/contracts";
|
||||
import { GatewayMediaExecutor } from "@ccr/core/media/executors";
|
||||
import type { GatewayMediaTarget, GatewayMediaTransport } from "@ccr/core/media/executors";
|
||||
import { grokMediaModelKind, isImportedGrokAgentProvider, migrateLegacyGrokMediaModelSelector, providerSupportsMediaKind } from "@ccr/core/media/models";
|
||||
import { detectMediaType, MediaArtifactStore, MediaJobStore } from "@ccr/core/media/storage";
|
||||
import { mediaToolBindingsForConfig } from "@ccr/core/media/tools";
|
||||
import type { MediaToolBinding } from "@ccr/core/media/tools";
|
||||
import { activeProviderCredentials, inferProtocol, providerCapabilityInternalName, providerCredentialInternalName, sortProviderCredentialsForConfig } from "@ccr/core/providers/runtime-topology";
|
||||
import { modelRegistryForConfig, parseProviderModelSelector, providerRuntimeId } from "@ccr/core/routing/model-registry";
|
||||
|
||||
type QueueItem = {
|
||||
jobId: string;
|
||||
request?: MediaRequest;
|
||||
resumeRemoteRequestId?: string;
|
||||
};
|
||||
|
||||
type Completion = {
|
||||
promise: Promise<MediaJob>;
|
||||
resolve: (job: MediaJob) => void;
|
||||
};
|
||||
|
||||
const mediaRoot = path.join(CONFIGDIR, "grok-media");
|
||||
const maxInputBytes = 25 * 1024 * 1024;
|
||||
const jobRetentionDays = 30;
|
||||
|
||||
export type { MediaToolBinding } from "@ccr/core/media/tools";
|
||||
|
||||
export class MediaService {
|
||||
private readonly active = new Map<string, AbortController>();
|
||||
private artifactStoreValue?: MediaArtifactStore;
|
||||
private cleanupTimer?: NodeJS.Timeout;
|
||||
private readonly completions = new Map<string, Completion>();
|
||||
private config?: AppConfig;
|
||||
private endpoint = "";
|
||||
private gatewayTransport?: GatewayMediaTransport;
|
||||
private jobStoreValue?: MediaJobStore;
|
||||
private queue: QueueItem[] = [];
|
||||
private running = false;
|
||||
private stopping = false;
|
||||
|
||||
constructor(private readonly rootDir = mediaRoot) {}
|
||||
|
||||
private get jobStore(): MediaJobStore {
|
||||
return this.jobStoreValue ??= new MediaJobStore(this.rootDir);
|
||||
}
|
||||
|
||||
private get artifactStore(): MediaArtifactStore {
|
||||
return this.artifactStoreValue ??= new MediaArtifactStore(this.rootDir);
|
||||
}
|
||||
|
||||
start(config: AppConfig, endpoint: string, gatewayTransport?: GatewayMediaTransport): void {
|
||||
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
this.config = structuredClone(config);
|
||||
this.endpoint = endpoint.replace(/\/+$/g, "");
|
||||
this.gatewayTransport = normalizeGatewayTransport(gatewayTransport ?? { baseUrl: endpoint });
|
||||
this.running = true;
|
||||
this.stopping = false;
|
||||
if (config.mediaTools.enabled) {
|
||||
this.recoverInterruptedJobs();
|
||||
this.startCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
updateConfig(config: AppConfig, endpoint: string, gatewayTransport?: GatewayMediaTransport): void {
|
||||
const wasEnabled = this.config?.mediaTools.enabled === true;
|
||||
this.config = structuredClone(config);
|
||||
this.endpoint = endpoint.replace(/\/+$/g, "");
|
||||
this.gatewayTransport = normalizeGatewayTransport(gatewayTransport ?? this.gatewayTransport ?? { baseUrl: endpoint });
|
||||
if (!wasEnabled && config.mediaTools.enabled) {
|
||||
this.recoverInterruptedJobs();
|
||||
this.startCleanup();
|
||||
} else if (wasEnabled && !config.mediaTools.enabled && this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
}
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
this.stopping = true;
|
||||
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
for (const controller of this.active.values()) controller.abort();
|
||||
const deadline = Date.now() + 3000;
|
||||
while (this.active.size && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
for (const item of this.queue) {
|
||||
const job = this.jobStore.get(item.jobId);
|
||||
if (!job) continue;
|
||||
if (item.resumeRemoteRequestId) {
|
||||
const next = this.jobStore.update(job.id, { status: "running" });
|
||||
this.completions.get(job.id)?.resolve(next);
|
||||
this.completions.delete(job.id);
|
||||
} else if (job.status === "queued") {
|
||||
this.finishCanceled(job, "CCR stopped before the media job started.");
|
||||
}
|
||||
}
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return Boolean(this.running && this.config?.mediaTools.enabled);
|
||||
}
|
||||
|
||||
async imageGenerate(args: Record<string, unknown>, modelSelector: string): Promise<PublicMediaJob> {
|
||||
const request: ImageGenerateRequest = {
|
||||
aspectRatio: optionalString(args.aspect_ratio),
|
||||
prompt: requiredPrompt(args.prompt)
|
||||
};
|
||||
return this.submitAndWait("image-generate", request, modelSelector, optionalString(args.idempotency_key));
|
||||
}
|
||||
|
||||
async imageEdit(args: Record<string, unknown>, modelSelector: string): Promise<PublicMediaJob> {
|
||||
const request: ImageEditRequest = {
|
||||
aspectRatio: optionalString(args.aspect_ratio),
|
||||
images: this.validateImages(args.images ?? args.image, 1, 3),
|
||||
prompt: requiredPrompt(args.prompt)
|
||||
};
|
||||
return this.submitAndWait("image-edit", request, modelSelector, optionalString(args.idempotency_key));
|
||||
}
|
||||
|
||||
videoStart(args: Record<string, unknown>, modelSelector: string): PublicMediaJob {
|
||||
const durationValue = numberValue(args.duration) ?? 6;
|
||||
if (durationValue !== 6 && durationValue !== 10) throw new Error("duration must be 6 or 10 seconds.");
|
||||
const resolution = optionalString(args.resolution) ?? "480p";
|
||||
if (resolution !== "480p" && resolution !== "720p") throw new Error("resolution must be 480p or 720p.");
|
||||
const request: VideoGenerateRequest = {
|
||||
aspectRatio: optionalString(args.aspect_ratio),
|
||||
duration: durationValue,
|
||||
images: args.images === undefined && args.image === undefined ? [] : this.validateImages(args.images ?? args.image, 1, 7),
|
||||
prompt: requiredPrompt(args.prompt),
|
||||
resolution
|
||||
};
|
||||
const job = this.submit("video-generate", request, modelSelector, optionalString(args.idempotency_key));
|
||||
return this.publicJob(job);
|
||||
}
|
||||
|
||||
getJob(id: string): PublicMediaJob {
|
||||
const job = this.jobStore.get(id);
|
||||
if (!job) throw new Error(`Media job not found: ${id}`);
|
||||
return this.publicJob(job);
|
||||
}
|
||||
|
||||
cancelJob(id: string): PublicMediaJob {
|
||||
const job = this.jobStore.get(id);
|
||||
if (!job) throw new Error(`Media job not found: ${id}`);
|
||||
if (["canceled", "failed", "succeeded"].includes(job.status)) return this.publicJob(job);
|
||||
this.queue = this.queue.filter((item) => item.jobId !== id);
|
||||
this.active.get(id)?.abort();
|
||||
return this.publicJob(this.finishCanceled(job, "Canceled by MCP client."));
|
||||
}
|
||||
|
||||
capabilities(): Record<string, unknown> {
|
||||
const config = this.requireConfig();
|
||||
const runtime = config.mediaTools;
|
||||
return {
|
||||
backend: "gateway-media-api",
|
||||
bindings: this.toolBindings(),
|
||||
constraints: {
|
||||
imageEditMaxInputs: 3,
|
||||
inputFileMaxBytes: maxInputBytes,
|
||||
videoReferenceMaxInputs: 7,
|
||||
videoDurations: [6, 10],
|
||||
videoResolutions: ["480p", "720p"]
|
||||
},
|
||||
enabled: runtime.enabled,
|
||||
operations: ["image-generate", "image-edit", "video-generate", "image-to-video", "reference-to-video"]
|
||||
};
|
||||
}
|
||||
|
||||
toolBindings(): MediaToolBinding[] {
|
||||
return mediaToolBindingsForConfig(this.requireConfig());
|
||||
}
|
||||
|
||||
bindingForTool(name: string): MediaToolBinding | undefined {
|
||||
return this.toolBindings().find((binding) => binding.name === name);
|
||||
}
|
||||
|
||||
resolveArtifact(id: string, token: string): { artifact: NonNullable<MediaJob["artifact"]>; state: "expired" | "missing" | "ok" } {
|
||||
const artifact = this.jobStore.list().map((job) => job.artifact).find((item) => item?.id === id);
|
||||
if (!artifact || !safeTokenEqual(artifact.accessToken, token)) return { artifact: undefined as never, state: "missing" };
|
||||
if (Date.parse(artifact.expiresAt) <= Date.now() || !existsSync(artifact.localPath)) return { artifact, state: "expired" };
|
||||
return { artifact, state: "ok" };
|
||||
}
|
||||
|
||||
private async submitAndWait(operation: MediaOperation, request: MediaRequest, modelSelector: string, idempotencyKey: string | undefined): Promise<PublicMediaJob> {
|
||||
const job = this.submit(operation, request, modelSelector, idempotencyKey);
|
||||
if (job.status !== "queued" && job.status !== "running") return this.publicJob(job);
|
||||
const completion = this.completions.get(job.id);
|
||||
return this.publicJob(completion ? await completion.promise : this.jobStore.get(job.id) ?? job);
|
||||
}
|
||||
|
||||
private submit(operation: MediaOperation, request: MediaRequest, modelSelector: string, idempotencyKey?: string): MediaJob {
|
||||
this.requireEnabledConfig();
|
||||
const normalizedModelSelector = normalizeMediaModelSelector(this.requireConfig(), modelSelector, operation);
|
||||
resolveProviderMediaTarget(this.requireConfig(), normalizedModelSelector, operation);
|
||||
const idempotencyKeyHash = idempotencyKey ? createHash("sha256").update(`${normalizedModelSelector}\n${idempotencyKey}`).digest("hex") : undefined;
|
||||
if (idempotencyKeyHash) {
|
||||
const existing = this.jobStore.list().find((job) => job.operation === operation && job.idempotencyKeyHash === idempotencyKeyHash);
|
||||
if (existing) return existing;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const job: MediaJob = {
|
||||
backend: "gateway-media-api",
|
||||
createdAt: now,
|
||||
id: randomUUID(),
|
||||
...(idempotencyKeyHash ? { idempotencyKeyHash } : {}),
|
||||
modelSelector: normalizedModelSelector,
|
||||
operation,
|
||||
status: "queued",
|
||||
updatedAt: now
|
||||
};
|
||||
this.jobStore.put(job);
|
||||
this.completions.set(job.id, createCompletion());
|
||||
this.queue.push({ jobId: job.id, request });
|
||||
this.schedule();
|
||||
return job;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.running || !this.config?.mediaTools.enabled) return;
|
||||
for (let index = 0; index < this.queue.length;) {
|
||||
const item = this.queue[index];
|
||||
const job = this.jobStore.get(item.jobId);
|
||||
if (!job || !this.hasCapacity(job.operation)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
this.queue.splice(index, 1);
|
||||
void this.run(item, job);
|
||||
}
|
||||
}
|
||||
|
||||
private hasCapacity(operation: MediaOperation): boolean {
|
||||
const config = this.requireRuntimeConfig();
|
||||
const video = operation === "video-generate";
|
||||
const activeCount = [...this.active.keys()].map((id) => this.jobStore.get(id)).filter((job) => job && (job.operation === "video-generate") === video).length;
|
||||
return activeCount < (video ? config.maxVideoConcurrency : config.maxImageConcurrency);
|
||||
}
|
||||
|
||||
private async run(item: QueueItem, initialJob: MediaJob): Promise<void> {
|
||||
const config = this.requireRuntimeConfig();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.jobTimeoutMs);
|
||||
this.active.set(initialJob.id, controller);
|
||||
let job = this.jobStore.update(initialJob.id, { startedAt: initialJob.startedAt ?? new Date().toISOString(), status: "running" });
|
||||
try {
|
||||
const context: MediaExecutionContext = {
|
||||
job,
|
||||
onRemoteRequestId: (remoteRequestId) => {
|
||||
job = this.jobStore.update(job.id, { remoteRequestId });
|
||||
},
|
||||
signal: controller.signal
|
||||
};
|
||||
const result = item.resumeRemoteRequestId
|
||||
? await this.executor(jobModelSelector(job), job.operation).resumeVideo(item.resumeRemoteRequestId, controller.signal)
|
||||
: await this.execute(job, item.request!, context);
|
||||
const artifact = await this.importResult(result, controller.signal, config.artifactTtlHours, jobModelSelector(job));
|
||||
job = this.jobStore.update(job.id, {
|
||||
artifact,
|
||||
error: undefined,
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: "succeeded",
|
||||
usage: result.usage
|
||||
});
|
||||
} catch (error) {
|
||||
const current = this.jobStore.get(job.id) ?? job;
|
||||
if (current.status === "canceled") {
|
||||
job = current;
|
||||
} else if (this.stopping && isProviderApiJob(current) && current.remoteRequestId) {
|
||||
job = this.jobStore.update(job.id, { status: "running" });
|
||||
} else if (this.stopping) {
|
||||
job = this.jobStore.update(job.id, {
|
||||
error: { code: "interrupted", message: "CCR stopped before the media request completed. The request was not automatically resubmitted.", retryable: true },
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: "failed"
|
||||
});
|
||||
} else {
|
||||
const normalized = normalizeJobError(error, controller.signal.aborted);
|
||||
job = this.jobStore.update(job.id, {
|
||||
error: normalized,
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: normalized.code === "canceled" ? "canceled" : "failed"
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
this.active.delete(job.id);
|
||||
this.completions.get(job.id)?.resolve(job);
|
||||
this.completions.delete(job.id);
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
private execute(job: MediaJob, request: MediaRequest, context: MediaExecutionContext): Promise<MediaExecutionResult> {
|
||||
const executor = this.executor(jobModelSelector(job), job.operation);
|
||||
if (job.operation === "image-generate") return executor.imageGenerate(request as ImageGenerateRequest, context);
|
||||
if (job.operation === "image-edit") return executor.imageEdit(request as ImageEditRequest, context);
|
||||
return executor.videoGenerate(request as VideoGenerateRequest, context);
|
||||
}
|
||||
|
||||
private async importResult(result: MediaExecutionResult, signal: AbortSignal, ttlHours: number, modelSelector: string): Promise<MediaArtifact> {
|
||||
if (result.filePath) {
|
||||
try {
|
||||
return this.artifactStore.importFile(result.filePath, { contentType: result.contentType, fileName: result.fileName, ttlHours });
|
||||
} finally {
|
||||
if (isPathInside(result.filePath, os.tmpdir())) rmSync(result.filePath, { force: true });
|
||||
}
|
||||
}
|
||||
const downloaded = await this.executor(modelSelector).download(result, signal);
|
||||
return this.importResult(downloaded, signal, ttlHours, modelSelector);
|
||||
}
|
||||
|
||||
private recoverInterruptedJobs(): void {
|
||||
for (const job of this.jobStore.list()) {
|
||||
if (job.status !== "queued" && job.status !== "running") continue;
|
||||
if (this.active.has(job.id) || this.queue.some((item) => item.jobId === job.id)) continue;
|
||||
if (isProviderApiJob(job) && job.operation === "video-generate" && job.remoteRequestId && jobModelSelector(job, false)) {
|
||||
this.completions.set(job.id, createCompletion());
|
||||
if (!this.queue.some((item) => item.jobId === job.id)) this.queue.push({ jobId: job.id, resumeRemoteRequestId: job.remoteRequestId });
|
||||
} else {
|
||||
this.jobStore.update(job.id, {
|
||||
error: { code: "interrupted", message: "CCR restarted before the media request completed. The request was not automatically resubmitted.", retryable: true },
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: "failed"
|
||||
});
|
||||
}
|
||||
}
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private startCleanup(): void {
|
||||
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
||||
this.cleanup();
|
||||
this.cleanupTimer = setInterval(() => this.cleanup(), 60 * 60 * 1000);
|
||||
this.cleanupTimer.unref?.();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const job of this.jobStore.list()) {
|
||||
if (job.artifact && Date.parse(job.artifact.expiresAt) <= now) this.artifactStore.delete(job.artifact);
|
||||
}
|
||||
for (const job of this.jobStore.deleteOlderThan(now - jobRetentionDays * 24 * 60 * 60 * 1000)) this.artifactStore.delete(job.artifact);
|
||||
}
|
||||
|
||||
private validateImages(value: unknown, min: number, max: number): string[] {
|
||||
const raw = typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
|
||||
if (raw.length < min || raw.length > max || raw.some((item) => typeof item !== "string" || !item.trim())) {
|
||||
throw new Error(`images must contain between ${min} and ${max} local image paths.`);
|
||||
}
|
||||
const roots = mediaInputRoots(this.requireRuntimeConfig().allowedInputRoots);
|
||||
return raw.map((item) => {
|
||||
const resolved = realpathSync(expandHome(String(item).trim()));
|
||||
if (!roots.some((root) => isPathInside(resolved, root))) throw new Error(`Input image is outside allowed roots: ${resolved}`);
|
||||
const stats = statSync(resolved);
|
||||
if (!stats.isFile() || stats.size <= 0 || stats.size > maxInputBytes) throw new Error(`Input image must be a non-empty regular file no larger than ${maxInputBytes} bytes.`);
|
||||
if (!detectMediaType(resolved).mimeType?.startsWith("image/")) throw new Error(`Unsupported input image format: ${resolved}`);
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
|
||||
private finishCanceled(job: MediaJob, message: string): MediaJob {
|
||||
const next = this.jobStore.update(job.id, {
|
||||
error: { code: "canceled", message, retryable: false },
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: "canceled"
|
||||
});
|
||||
this.completions.get(job.id)?.resolve(next);
|
||||
this.completions.delete(job.id);
|
||||
return next;
|
||||
}
|
||||
|
||||
private publicJob(job: MediaJob): PublicMediaJob {
|
||||
const { artifact, idempotencyKeyHash: _idempotencyKeyHash, ...rest } = job;
|
||||
return {
|
||||
...rest,
|
||||
...(artifact ? { artifact: this.publicArtifact(artifact) } : {})
|
||||
};
|
||||
}
|
||||
|
||||
private publicArtifact(artifact: NonNullable<MediaJob["artifact"]>): PublicMediaArtifact {
|
||||
const { accessToken, ...rest } = artifact;
|
||||
const url = `${this.endpoint}/__ccr/media/artifacts/${encodeURIComponent(artifact.id)}?token=${encodeURIComponent(accessToken)}`;
|
||||
return {
|
||||
...rest,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
private requireConfig(): AppConfig {
|
||||
if (!this.config) throw new Error("Media service is not configured.");
|
||||
return this.config;
|
||||
}
|
||||
|
||||
private requireRuntimeConfig(): MediaToolsConfig {
|
||||
return this.requireConfig().mediaTools;
|
||||
}
|
||||
|
||||
private requireEnabledConfig(): MediaToolsConfig {
|
||||
const config = this.requireConfig();
|
||||
if (!this.running || !config.mediaTools.enabled) throw new Error("Media service is disabled.");
|
||||
return config.mediaTools;
|
||||
}
|
||||
|
||||
private executor(modelSelector: string, operation?: MediaOperation): GatewayMediaExecutor {
|
||||
const transport = this.gatewayTransport;
|
||||
if (!transport) throw new Error("Media gateway transport is not configured.");
|
||||
return new GatewayMediaExecutor(resolveProviderMediaTarget(this.requireConfig(), modelSelector, operation), transport);
|
||||
}
|
||||
}
|
||||
|
||||
function createCompletion(): Completion {
|
||||
let resolve!: (job: MediaJob) => void;
|
||||
const promise = new Promise<MediaJob>((value) => { resolve = value; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function requiredPrompt(value: unknown): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error("prompt is required.");
|
||||
const prompt = value.trim();
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeJobError(error: unknown, aborted: boolean): MediaJobError {
|
||||
if (aborted && !(error && typeof error === "object" && "code" in error && error.code === "canceled")) {
|
||||
return { code: "timeout", message: "Media job exceeded its configured timeout.", retryable: true };
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
const candidate = error as { code?: unknown; message?: unknown; retryable?: unknown };
|
||||
return {
|
||||
code: typeof candidate.code === "string" ? candidate.code : "media_error",
|
||||
message: typeof candidate.message === "string" ? candidate.message : String(error),
|
||||
retryable: candidate.retryable === true
|
||||
};
|
||||
}
|
||||
return { code: "media_error", message: String(error), retryable: false };
|
||||
}
|
||||
|
||||
function safeTokenEqual(expected: string, actual: string): boolean {
|
||||
const left = Buffer.from(expected);
|
||||
const right = Buffer.from(actual);
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function expandHome(value: string): string {
|
||||
return value === "~" ? os.homedir() : value.startsWith(`~${path.sep}`) ? path.join(os.homedir(), value.slice(2)) : value;
|
||||
}
|
||||
|
||||
function isPathInside(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function mediaInputRoots(allowedInputRoots: string[]): string[] {
|
||||
const workingDirectory = canonicalInputRoot(process.cwd());
|
||||
const homeDirectory = canonicalInputRoot(os.homedir());
|
||||
const roots = [
|
||||
...(isSafeImplicitWorkingDirectory(workingDirectory, homeDirectory) ? [workingDirectory] : []),
|
||||
os.tmpdir(),
|
||||
CONFIGDIR,
|
||||
...allowedInputRoots
|
||||
].map(canonicalInputRoot);
|
||||
return [...new Set(roots)];
|
||||
}
|
||||
|
||||
function canonicalInputRoot(value: string): string {
|
||||
const resolved = path.resolve(expandHome(value));
|
||||
return existsSync(resolved) ? realpathSync(resolved) : resolved;
|
||||
}
|
||||
|
||||
function isSafeImplicitWorkingDirectory(workingDirectory: string, homeDirectory: string): boolean {
|
||||
const resolvedWorkingDirectory = path.resolve(workingDirectory);
|
||||
const resolvedHomeDirectory = path.resolve(homeDirectory);
|
||||
return resolvedWorkingDirectory !== path.parse(resolvedWorkingDirectory).root &&
|
||||
!isPathInside(resolvedHomeDirectory, resolvedWorkingDirectory);
|
||||
}
|
||||
|
||||
function requiredModelSelector(value: string): string {
|
||||
const selector = value?.trim();
|
||||
if (!selector) throw new Error("A media model must be selected.");
|
||||
return selector;
|
||||
}
|
||||
|
||||
function isProviderApiJob(job: MediaJob): boolean {
|
||||
const backend = (job as unknown as { backend?: string }).backend;
|
||||
return backend === "gateway-media-api" || backend === "provider-api" || backend === "xai-api";
|
||||
}
|
||||
|
||||
function jobModelSelector(job: MediaJob): string;
|
||||
function jobModelSelector(job: MediaJob, required: false): string | undefined;
|
||||
function jobModelSelector(job: MediaJob, required = true): string | undefined {
|
||||
const selector = optionalString((job as Partial<MediaJob>).modelSelector);
|
||||
if (selector) return selector;
|
||||
if (required) throw new Error("This media job has no media API model binding and cannot be resumed.");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderMediaTarget(config: AppConfig, selector: string, operation?: MediaOperation): GatewayMediaTarget {
|
||||
const normalizedSelector = normalizeMediaModelSelector(config, selector, operation);
|
||||
const registry = modelRegistryForConfig(config);
|
||||
let resolved = registry.resolveProviderModel(normalizedSelector);
|
||||
if (!resolved) {
|
||||
const parsed = parseProviderModelSelector(normalizedSelector);
|
||||
const provider = parsed ? registry.findProvider(parsed.provider) : undefined;
|
||||
if (provider && isImportedGrokAgentProvider(provider) && grokMediaModelKind(parsed?.model)) {
|
||||
resolved = { model: parsed!.model, provider };
|
||||
}
|
||||
}
|
||||
if (!resolved) throw new Error(`Media model is not configured by a provider: ${normalizedSelector}`);
|
||||
const expectedKind = operation === "video-generate" ? "video" : operation ? "image" : undefined;
|
||||
const modelKind = grokMediaModelKind(resolved.model);
|
||||
if (expectedKind && modelKind && modelKind !== expectedKind) {
|
||||
throw new Error(`${resolved.model} is not a ${expectedKind} generation model.`);
|
||||
}
|
||||
const provider = resolved.provider;
|
||||
const kind = expectedKind ?? modelKind;
|
||||
if (!kind || !providerSupportsMediaKind(provider, kind)) {
|
||||
throw new Error(`Provider ${provider.name} does not declare ${kind ?? "media"} generation support.`);
|
||||
}
|
||||
const protocol: GatewayMediaProtocol = kind === "video"
|
||||
? "openai_video_generations"
|
||||
: "openai_image_generations";
|
||||
const capability = provider.capabilities?.find((item) => item.type === protocol);
|
||||
const providerBaseUrl = capability?.baseUrl ?? provider.baseurl ?? provider.baseUrl ?? provider.api_base_url;
|
||||
if (!providerBaseUrl?.trim()) {
|
||||
throw new Error(`Provider ${provider.name} does not configure a media API base URL.`);
|
||||
}
|
||||
const selectorProtocol = capability || isImportedGrokAgentProvider(provider)
|
||||
? protocol
|
||||
: inferProtocol(provider);
|
||||
const credential = sortProviderCredentialsForConfig(activeProviderCredentials(provider))[0];
|
||||
return {
|
||||
model: resolved.model,
|
||||
providerBaseUrl: providerBaseUrl.trim(),
|
||||
providerName: provider.name,
|
||||
providerSelector: credential
|
||||
? providerCredentialInternalName(provider, selectorProtocol, credential)
|
||||
: capability || isImportedGrokAgentProvider(provider)
|
||||
? providerCapabilityInternalName(provider, protocol)
|
||||
: providerRuntimeId(provider)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMediaModelSelector(config: AppConfig, selector: string, operation?: MediaOperation): string {
|
||||
const kind = operation === "video-generate" ? "video" : "image";
|
||||
const migrated = migrateLegacyGrokMediaModelSelector(config.Providers, requiredModelSelector(selector), kind);
|
||||
if (!migrated) {
|
||||
throw new Error("No compatible media API model is available. Select a provider model that declares image or video generation support.");
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function normalizeGatewayTransport(transport: GatewayMediaTransport): GatewayMediaTransport {
|
||||
return {
|
||||
...transport,
|
||||
baseUrl: transport.baseUrl.replace(/\/+$/g, "")
|
||||
};
|
||||
}
|
||||
|
||||
export const mediaService = new MediaService();
|
||||
|
||||
export const mediaServiceForTest = {
|
||||
isSafeImplicitWorkingDirectory
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { MediaArtifact, MediaJob } from "@ccr/core/media/contracts";
|
||||
|
||||
type JobStoreFile = {
|
||||
jobs: MediaJob[];
|
||||
version: 1;
|
||||
};
|
||||
|
||||
const privateDirectoryMode = 0o700;
|
||||
const privateFileMode = 0o600;
|
||||
const maxArtifactBytes = 250 * 1024 * 1024;
|
||||
|
||||
export class MediaJobStore {
|
||||
private readonly file: string;
|
||||
private readonly jobs = new Map<string, MediaJob>();
|
||||
|
||||
constructor(private readonly rootDir: string) {
|
||||
mkdirSync(rootDir, { mode: privateDirectoryMode, recursive: true });
|
||||
this.file = path.join(rootDir, "jobs.json");
|
||||
this.load();
|
||||
}
|
||||
|
||||
get(id: string): MediaJob | undefined {
|
||||
const job = this.jobs.get(id);
|
||||
return job ? structuredClone(job) : undefined;
|
||||
}
|
||||
|
||||
list(): MediaJob[] {
|
||||
return [...this.jobs.values()].map((job) => structuredClone(job));
|
||||
}
|
||||
|
||||
put(job: MediaJob): MediaJob {
|
||||
this.jobs.set(job.id, structuredClone(job));
|
||||
this.flush();
|
||||
return structuredClone(job);
|
||||
}
|
||||
|
||||
update(id: string, patch: Partial<MediaJob>): MediaJob {
|
||||
const current = this.jobs.get(id);
|
||||
if (!current) {
|
||||
throw new Error(`Media job not found: ${id}`);
|
||||
}
|
||||
const next: MediaJob = {
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
this.jobs.set(id, next);
|
||||
this.flush();
|
||||
return structuredClone(next);
|
||||
}
|
||||
|
||||
deleteOlderThan(cutoffMs: number): MediaJob[] {
|
||||
const deleted: MediaJob[] = [];
|
||||
for (const [id, job] of this.jobs) {
|
||||
const timestamp = Date.parse(job.finishedAt ?? job.updatedAt);
|
||||
if (["canceled", "failed", "succeeded"].includes(job.status) && Number.isFinite(timestamp) && timestamp < cutoffMs) {
|
||||
this.jobs.delete(id);
|
||||
deleted.push(job);
|
||||
}
|
||||
}
|
||||
if (deleted.length) this.flush();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.file)) return;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(this.file, "utf8")) as Partial<JobStoreFile>;
|
||||
if (!Array.isArray(parsed.jobs)) return;
|
||||
for (const job of parsed.jobs) {
|
||||
if (job && typeof job.id === "string") this.jobs.set(job.id, job);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[media-tools] Failed to load job store: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
mkdirSync(this.rootDir, { mode: privateDirectoryMode, recursive: true });
|
||||
const temporary = `${this.file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify({ jobs: this.list(), version: 1 }, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: privateFileMode
|
||||
});
|
||||
renameSync(temporary, this.file);
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaArtifactStore {
|
||||
readonly artifactsDir: string;
|
||||
|
||||
constructor(private readonly rootDir: string) {
|
||||
this.artifactsDir = path.join(rootDir, "artifacts");
|
||||
mkdirSync(this.artifactsDir, { mode: privateDirectoryMode, recursive: true });
|
||||
}
|
||||
|
||||
importFile(source: string, options: { contentType?: string; fileName?: string; ttlHours: number }): MediaArtifact {
|
||||
const sourceStats = statSync(source);
|
||||
if (!sourceStats.isFile() || sourceStats.size === 0) {
|
||||
throw new Error("Generated media artifact is empty or not a regular file.");
|
||||
}
|
||||
if (sourceStats.size > maxArtifactBytes) throw new Error("Generated media artifact exceeds the 250 MB limit.");
|
||||
const detected = detectMediaType(source);
|
||||
const mimeType = detected.mimeType;
|
||||
if (!mimeType) throw new Error("Generated file is not a supported image or video artifact.");
|
||||
const id = randomUUID();
|
||||
const extension = extensionForMimeType(mimeType) ?? detected.extension;
|
||||
const fileName = fileNameWithExtension(options.fileName ?? `media-${id}${extension}`, extension);
|
||||
const destination = path.join(this.artifactsDir, `${id}${extension}`);
|
||||
copyFileSync(source, destination);
|
||||
return this.describe(destination, id, fileName, mimeType, options.ttlHours);
|
||||
}
|
||||
|
||||
writeBuffer(buffer: Buffer, options: { contentType?: string; fileName?: string; ttlHours: number }): MediaArtifact {
|
||||
if (buffer.byteLength === 0) throw new Error("Generated media artifact is empty.");
|
||||
if (buffer.byteLength > maxArtifactBytes) throw new Error("Generated media artifact exceeds the 250 MB limit.");
|
||||
const mimeType = detectMediaBufferType(buffer)?.mimeType;
|
||||
if (!mimeType) throw new Error("Generated response is not a supported image or video artifact.");
|
||||
const id = randomUUID();
|
||||
const extension = extensionForMimeType(mimeType) ?? ".bin";
|
||||
const destination = path.join(this.artifactsDir, `${id}${extension}`);
|
||||
writeFileSync(destination, buffer, { mode: privateFileMode });
|
||||
return this.describe(destination, id, fileNameWithExtension(options.fileName ?? `media-${id}${extension}`, extension), mimeType, options.ttlHours);
|
||||
}
|
||||
|
||||
delete(artifact: MediaArtifact | undefined): void {
|
||||
if (!artifact) return;
|
||||
const resolved = path.resolve(artifact.localPath);
|
||||
if (!isPathInside(resolved, this.artifactsDir)) return;
|
||||
rmSync(resolved, { force: true });
|
||||
}
|
||||
|
||||
private describe(file: string, id: string, fileName: string, mimeType: string, ttlHours: number): MediaArtifact {
|
||||
const sizeBytes = statSync(file).size;
|
||||
return {
|
||||
accessToken: randomBytes(24).toString("base64url"),
|
||||
expiresAt: new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString(),
|
||||
fileName,
|
||||
id,
|
||||
localPath: file,
|
||||
mimeType,
|
||||
sha256: hashFile(file),
|
||||
sizeBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function detectMediaType(file: string): { extension: string; mimeType?: string } {
|
||||
const descriptor = Buffer.alloc(32);
|
||||
const handle = openSync(file, "r");
|
||||
const length = readSync(handle, descriptor, 0, descriptor.length, 0);
|
||||
closeSync(handle);
|
||||
return detectMediaBufferType(descriptor.subarray(0, length)) ?? { extension: path.extname(file).toLowerCase() || ".bin" };
|
||||
}
|
||||
|
||||
function hashFile(file: string): string {
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
const handle = openSync(file, "r");
|
||||
try {
|
||||
while (true) {
|
||||
const length = readSync(handle, buffer, 0, buffer.length, null);
|
||||
if (!length) break;
|
||||
hash.update(buffer.subarray(0, length));
|
||||
}
|
||||
} finally {
|
||||
closeSync(handle);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function detectMediaBufferType(buffer: Buffer): { extension: string; mimeType: string } | undefined {
|
||||
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return { extension: ".png", mimeType: "image/png" };
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return { extension: ".jpg", mimeType: "image/jpeg" };
|
||||
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return { extension: ".webp", mimeType: "image/webp" };
|
||||
if (buffer.subarray(4, 8).toString("ascii") === "ftyp") {
|
||||
const brand = buffer.subarray(8, 12).toString("ascii");
|
||||
if (["avif", "avis", "mif1", "msf1"].includes(brand)) return { extension: ".avif", mimeType: "image/avif" };
|
||||
return { extension: ".mp4", mimeType: "video/mp4" };
|
||||
}
|
||||
if (buffer.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) return { extension: ".webm", mimeType: "video/webm" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extensionForMimeType(mimeType: string): string | undefined {
|
||||
return ({
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/avif": ".avif",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm"
|
||||
} as Record<string, string>)[mimeType];
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
return path.basename(value).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "media.bin";
|
||||
}
|
||||
|
||||
function fileNameWithExtension(value: string, extension: string): string {
|
||||
const sanitized = sanitizeFileName(value);
|
||||
return path.extname(sanitized) ? sanitized : `${sanitized}${extension}`;
|
||||
}
|
||||
|
||||
function isPathInside(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
GROK_MEDIA_CAPABILITIES_TOOL_NAME,
|
||||
GROK_MEDIA_IMAGE_EDIT_TOOL_NAME,
|
||||
GROK_MEDIA_IMAGE_GENERATE_TOOL_NAME,
|
||||
GROK_MEDIA_JOB_CANCEL_TOOL_NAME,
|
||||
GROK_MEDIA_JOB_GET_TOOL_NAME,
|
||||
GROK_MEDIA_VIDEO_START_TOOL_NAME
|
||||
} from "@ccr/core/contracts/app";
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import type { MediaOperation } from "@ccr/core/media/contracts";
|
||||
import { defaultGrokMediaModelSelector, migrateLegacyGrokMediaModelSelector } from "@ccr/core/media/models";
|
||||
|
||||
export type MediaToolBinding = {
|
||||
modelSelector: string;
|
||||
name: string;
|
||||
operation: MediaOperation | "capabilities" | "job-cancel" | "job-get";
|
||||
};
|
||||
|
||||
export type MediaMcpToolDefinition = {
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function mediaToolBindingsForConfig(config: Pick<AppConfig, "Providers" | "virtualModelProfiles">): MediaToolBinding[] {
|
||||
const providers = config.Providers ?? [];
|
||||
const bindings: MediaToolBinding[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (binding: MediaToolBinding) => {
|
||||
if (!binding.name || !binding.modelSelector || seen.has(binding.name)) return;
|
||||
seen.add(binding.name);
|
||||
bindings.push(binding);
|
||||
};
|
||||
|
||||
for (const profile of config.virtualModelProfiles ?? []) {
|
||||
if (profile.enabled === false) continue;
|
||||
const media = readFusionMediaConfig(profile.metadata?.fusionMedia);
|
||||
if (!media) continue;
|
||||
const imageModelSelector = migrateLegacyGrokMediaModelSelector(providers, media.imageModelSelector, "image");
|
||||
const videoModelSelector = migrateLegacyGrokMediaModelSelector(providers, media.videoModelSelector, "video");
|
||||
if (imageModelSelector) {
|
||||
if (media.imageGenerateToolName) add({ modelSelector: imageModelSelector, name: media.imageGenerateToolName, operation: "image-generate" });
|
||||
if (media.imageEditToolName) add({ modelSelector: imageModelSelector, name: media.imageEditToolName, operation: "image-edit" });
|
||||
}
|
||||
if (videoModelSelector) {
|
||||
if (media.videoStartToolName) add({ modelSelector: videoModelSelector, name: media.videoStartToolName, operation: "video-generate" });
|
||||
if (media.jobGetToolName) add({ modelSelector: videoModelSelector, name: media.jobGetToolName, operation: "job-get" });
|
||||
if (media.jobCancelToolName) add({ modelSelector: videoModelSelector, name: media.jobCancelToolName, operation: "job-cancel" });
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles produced by the first Grok-only implementation did not carry model bindings.
|
||||
// Migrate their tool names to an available Grok API model without invoking Grok CLI.
|
||||
const legacyToolNames = new Set(
|
||||
(config.virtualModelProfiles ?? [])
|
||||
.filter((profile) => profile.enabled !== false)
|
||||
.flatMap((profile) => Array.isArray(profile.tools) ? profile.tools.map((tool) => tool.name) : [])
|
||||
);
|
||||
const defaultImageModel = defaultGrokMediaModelSelector(providers, "image");
|
||||
const defaultVideoModel = defaultGrokMediaModelSelector(providers, "video");
|
||||
if (defaultImageModel && legacyToolNames.has(GROK_MEDIA_IMAGE_GENERATE_TOOL_NAME)) add({ modelSelector: defaultImageModel, name: GROK_MEDIA_IMAGE_GENERATE_TOOL_NAME, operation: "image-generate" });
|
||||
if (defaultImageModel && legacyToolNames.has(GROK_MEDIA_IMAGE_EDIT_TOOL_NAME)) add({ modelSelector: defaultImageModel, name: GROK_MEDIA_IMAGE_EDIT_TOOL_NAME, operation: "image-edit" });
|
||||
if (defaultVideoModel && legacyToolNames.has(GROK_MEDIA_VIDEO_START_TOOL_NAME)) add({ modelSelector: defaultVideoModel, name: GROK_MEDIA_VIDEO_START_TOOL_NAME, operation: "video-generate" });
|
||||
if (defaultVideoModel && legacyToolNames.has(GROK_MEDIA_JOB_GET_TOOL_NAME)) add({ modelSelector: defaultVideoModel, name: GROK_MEDIA_JOB_GET_TOOL_NAME, operation: "job-get" });
|
||||
if (defaultVideoModel && legacyToolNames.has(GROK_MEDIA_JOB_CANCEL_TOOL_NAME)) add({ modelSelector: defaultVideoModel, name: GROK_MEDIA_JOB_CANCEL_TOOL_NAME, operation: "job-cancel" });
|
||||
if ((defaultImageModel ?? defaultVideoModel) && legacyToolNames.has(GROK_MEDIA_CAPABILITIES_TOOL_NAME)) add({ modelSelector: (defaultImageModel ?? defaultVideoModel)!, name: GROK_MEDIA_CAPABILITIES_TOOL_NAME, operation: "capabilities" });
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function mediaMcpToolDefinition(binding: MediaToolBinding): MediaMcpToolDefinition {
|
||||
if (binding.operation === "image-generate") return {
|
||||
description: `Generate an image through the selected media provider with ${binding.modelSelector}. This call waits for completion and returns a durable local artifact plus an expiring URL.`,
|
||||
inputSchema: objectSchema({
|
||||
aspect_ratio: { description: "Optional aspect ratio such as 1:1, 16:9, 9:16, 4:3, or 3:2.", type: "string" },
|
||||
idempotency_key: { description: "Stable caller-generated key that prevents duplicate paid submissions.", type: "string" },
|
||||
prompt: { description: "Image generation prompt.", maxLength: 20000, type: "string" }
|
||||
}, ["prompt"]),
|
||||
name: binding.name
|
||||
};
|
||||
if (binding.operation === "image-edit") return {
|
||||
description: `Edit one to three local images with ${binding.modelSelector}.`,
|
||||
inputSchema: objectSchema({
|
||||
aspect_ratio: { description: "Optional output aspect ratio.", type: "string" },
|
||||
idempotency_key: { description: "Stable caller-generated key that prevents duplicate paid submissions.", type: "string" },
|
||||
images: { description: "One to three absolute local image paths.", items: { type: "string" }, maxItems: 3, minItems: 1, type: "array" },
|
||||
prompt: { description: "Editing instruction.", maxLength: 20000, type: "string" }
|
||||
}, ["images", "prompt"]),
|
||||
name: binding.name
|
||||
};
|
||||
if (binding.operation === "video-generate") return {
|
||||
description: `Start a media provider video job with ${binding.modelSelector}. Returns immediately with a job id. Supply zero images for text-to-video, one for image-to-video, or two to seven reference images.`,
|
||||
inputSchema: objectSchema({
|
||||
aspect_ratio: { description: "Optional output aspect ratio.", type: "string" },
|
||||
duration: { description: "Video duration in seconds.", enum: [6, 10], type: "number" },
|
||||
idempotency_key: { description: "Stable caller-generated key that prevents duplicate paid submissions.", type: "string" },
|
||||
images: { description: "Up to seven absolute local image paths.", items: { type: "string" }, maxItems: 7, type: "array" },
|
||||
prompt: { description: "Video generation prompt.", maxLength: 20000, type: "string" },
|
||||
resolution: { description: "Requested output resolution.", enum: ["480p", "720p"], type: "string" }
|
||||
}, ["prompt"]),
|
||||
name: binding.name
|
||||
};
|
||||
if (binding.operation === "job-get") return {
|
||||
description: "Get the current state and artifact of a media job.",
|
||||
inputSchema: objectSchema({ job_id: { description: "Job id returned by a start or image call.", type: "string" } }, ["job_id"]),
|
||||
name: binding.name
|
||||
};
|
||||
if (binding.operation === "job-cancel") return {
|
||||
description: "Cancel a queued or running media job. Remote providers may already have billed a submitted request.",
|
||||
inputSchema: objectSchema({ job_id: { description: "Job id to cancel.", type: "string" } }, ["job_id"]),
|
||||
name: binding.name
|
||||
};
|
||||
return {
|
||||
description: "Return available media model bindings and constraints.",
|
||||
inputSchema: objectSchema({}),
|
||||
name: binding.name
|
||||
};
|
||||
}
|
||||
|
||||
function readFusionMediaConfig(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ["imageEditToolName", "imageGenerateToolName", "imageModelSelector", "jobCancelToolName", "jobGetToolName", "videoModelSelector", "videoStartToolName"]) {
|
||||
const item = readString(value[key]);
|
||||
if (item) result[key] = item;
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
function objectSchema(properties: Record<string, unknown>, required: string[] = []): Record<string, unknown> {
|
||||
return { additionalProperties: false, properties, ...(required.length ? { required } : {}), type: "object" };
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } fro
|
||||
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch";
|
||||
import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment";
|
||||
import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch";
|
||||
import { CodexAppMediaPreviewBridge, shouldEnableCodexMediaPreviewBridge } from "@ccr/core/agents/codex/media-preview-bridge";
|
||||
import { findRunningOpenCodeAppPid, launchOpenCodeAppProfile, openCodeAppLaunchSignature } from "@ccr/core/agents/opencode/app-launch";
|
||||
import { writeOpenCodeGatewayConfig } from "@ccr/core/agents/opencode/profile-config";
|
||||
import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import { gatewayService } from "@ccr/core/gateway/service";
|
||||
import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config";
|
||||
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
|
||||
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
|
||||
import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service";
|
||||
import { windowsEnvironmentChangedPowerShellLines, windowsSystemCommand } from "@ccr/core/platform/windows-system";
|
||||
@@ -23,6 +25,17 @@ const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<";
|
||||
export const desktopCliCommandName = "ccr-app";
|
||||
const desktopCliRuntimeFileName = "ccr-cli.js";
|
||||
const desktopCliCommandNameEnv = "CCR_CLI_COMMAND_NAME";
|
||||
export const CCR_CLI_COMPANION_RUNTIME_FILE_NAMES = [
|
||||
"browser-web-search-proxy-mcp.js",
|
||||
"fusion-tool-fallback-mcp.js",
|
||||
"fusion-vision-mcp.js",
|
||||
"media-tools-proxy-mcp.js",
|
||||
"next-ai-gateway.js",
|
||||
"request-log-worker.js",
|
||||
"route-script-worker.js",
|
||||
"undici-proxy-agent.js",
|
||||
"upstream-header-sanitizer.js"
|
||||
] as const;
|
||||
let claudeAppBotWorker: ChildProcess | undefined;
|
||||
let claudeAppBotWorkerProfileId: string | undefined;
|
||||
let claudeAppBotWorkerStateDir: string | undefined;
|
||||
@@ -31,6 +44,7 @@ let openCodeAppBotWorkerProfileId: string | undefined;
|
||||
let openCodeAppBotWorkerSignature: string | undefined;
|
||||
let openCodeAppBotWorkerStateDir: string | undefined;
|
||||
const codexAppBotWorkers = new Map<string, { agent: ProfileConfig["agent"]; child: ChildProcess; stateDir?: string }>();
|
||||
const codexAppMediaPreviewBridges = new Map<string, { bridge: CodexAppMediaPreviewBridge; signature: string }>();
|
||||
|
||||
type ProfileOpenCommandOptions = {
|
||||
commandName?: string;
|
||||
@@ -64,10 +78,11 @@ type ProfileAppLaunchResult = {
|
||||
};
|
||||
|
||||
type RunningProfileApp = ProfileRuntimeEntry & {
|
||||
child: ChildProcess;
|
||||
child?: ChildProcess;
|
||||
claudeDesignProxy?: boolean;
|
||||
command: string;
|
||||
launchSignature?: string;
|
||||
monitor?: NodeJS.Timeout;
|
||||
pidIsLauncher?: boolean;
|
||||
spawnError?: string;
|
||||
stopRequested?: boolean;
|
||||
@@ -78,6 +93,7 @@ process.once("exit", () => {
|
||||
stopClaudeAppBotWorker();
|
||||
stopOpenCodeAppBotWorker();
|
||||
stopCodexAppBotWorker();
|
||||
stopCodexAppMediaPreviewBridge();
|
||||
});
|
||||
|
||||
export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest, options: ProfileOpenCommandOptions = {}): Promise<ProfileOpenCommandResult> {
|
||||
@@ -243,6 +259,7 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof
|
||||
if (existing) {
|
||||
refreshCodexCompatibleAppProfileFiles(CONFIGDIR, profile, profileGatewayConfig);
|
||||
startCodexAppBotWorker(profileGatewayConfig, profile);
|
||||
startCodexAppMediaPreviewBridge(profileGatewayConfig, profile, existing.userDataDir);
|
||||
activateProfileAppWindow(existing);
|
||||
return {
|
||||
message: `${appName} is already running with ${profile.name || profile.id}.`,
|
||||
@@ -251,6 +268,26 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof
|
||||
surface: "app"
|
||||
};
|
||||
}
|
||||
if (profile.agent === "codex") {
|
||||
const files = refreshCodexCompatibleAppProfileFiles(CONFIGDIR, profile, profileGatewayConfig);
|
||||
const unmanagedPid = profileAppMainPid({ userDataDir: files.userDataDir });
|
||||
if (unmanagedPid) {
|
||||
const entry = registerExistingProfileApp(profile, "app", {
|
||||
command: appName,
|
||||
pid: unmanagedPid,
|
||||
userDataDir: files.userDataDir
|
||||
});
|
||||
startCodexAppBotWorker(profileGatewayConfig, profile);
|
||||
startCodexAppMediaPreviewBridge(profileGatewayConfig, profile, entry.userDataDir);
|
||||
activateProfileAppWindow(entry);
|
||||
return {
|
||||
message: `${appName} is already running with ${profile.name || profile.id}.`,
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
surface: "app"
|
||||
};
|
||||
}
|
||||
}
|
||||
const launch = profile.agent === "zcode"
|
||||
? launchZcodeAppProfile(CONFIGDIR, profile, profileGatewayConfig)
|
||||
: launchCodexAppProfile(CONFIGDIR, profile, profileGatewayConfig);
|
||||
@@ -268,6 +305,7 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof
|
||||
}
|
||||
activateProfileAppWindow(entry);
|
||||
startCodexAppBotWorker(profileGatewayConfig, profile);
|
||||
startCodexAppMediaPreviewBridge(profileGatewayConfig, profile, entry.userDataDir);
|
||||
return {
|
||||
message: `Opened ${appName} with ${profile.name || profile.id}.`,
|
||||
profileId: profile.id,
|
||||
@@ -756,6 +794,7 @@ export async function stopProfileFromCcr(config: AppConfig, request: ProfileOpen
|
||||
stopOpenCodeAppBotWorker(profile.id);
|
||||
} else if (profile.agent === "codex" || profile.agent === "zcode") {
|
||||
stopCodexAppBotWorker(profile.id);
|
||||
stopCodexAppMediaPreviewBridge(profile.id);
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -821,6 +860,32 @@ function registerProfileApp(
|
||||
return entry;
|
||||
}
|
||||
|
||||
function registerExistingProfileApp(
|
||||
profile: ReturnType<typeof findProfileForOpen>,
|
||||
surface: ProfileOpenRequest["surface"],
|
||||
existing: { command: string; pid: number; userDataDir: string }
|
||||
): RunningProfileApp {
|
||||
const key = profileRuntimeKey(profile.id, surface);
|
||||
const entry: RunningProfileApp = {
|
||||
agent: profile.agent,
|
||||
command: existing.command,
|
||||
pid: existing.pid,
|
||||
pidIsLauncher: true,
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
startedAt: new Date().toISOString(),
|
||||
state: "running",
|
||||
surface,
|
||||
userDataDir: existing.userDataDir
|
||||
};
|
||||
entry.monitor = setInterval(() => {
|
||||
if (!isProfileAppRunning(entry)) cleanupProfileAppEntry(key, entry);
|
||||
}, 2_000);
|
||||
entry.monitor.unref?.();
|
||||
runningProfileApps.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function activateProfileAppWindow(entry: Pick<RunningProfileApp, "pid" | "userDataDir">): void {
|
||||
if (process.platform !== "darwin") {
|
||||
return;
|
||||
@@ -887,6 +952,7 @@ function cleanupProfileAppEntry(key: string, entry: RunningProfileApp): void {
|
||||
if (runningProfileApps.get(key) !== entry) {
|
||||
return;
|
||||
}
|
||||
if (entry.monitor) clearInterval(entry.monitor);
|
||||
runningProfileApps.delete(key);
|
||||
if (entry.agent === "claude-code") {
|
||||
stopClaudeAppBotWorker(entry.profileId);
|
||||
@@ -896,19 +962,20 @@ function cleanupProfileAppEntry(key: string, entry: RunningProfileApp): void {
|
||||
}
|
||||
if (entry.agent === "codex" || entry.agent === "zcode") {
|
||||
stopCodexAppBotWorker(entry.profileId);
|
||||
stopCodexAppMediaPreviewBridge(entry.profileId);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRunningProfileApp(key: string, entry: RunningProfileApp): Promise<boolean> {
|
||||
if (!isProfileAppRunning(entry)) {
|
||||
runningProfileApps.delete(key);
|
||||
cleanupProfileAppEntry(key, entry);
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.stopRequested = true;
|
||||
sendProfileProcessSignal(profileAppMainPid(entry) ?? entry.pid, "SIGTERM");
|
||||
if (await waitForProfileAppExit(entry, 5000)) {
|
||||
runningProfileApps.delete(key);
|
||||
cleanupProfileAppEntry(key, entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -967,13 +1034,7 @@ function posixProfileAppMainPid(marker: string): number | undefined {
|
||||
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) {
|
||||
continue;
|
||||
}
|
||||
if (path.basename(command.trim().split(/\s+/)[0] || "") === "open") {
|
||||
continue;
|
||||
}
|
||||
if (command.includes(" --type=")) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeProcessPath(command).includes(marker)) {
|
||||
if (isProfileAppMainProcessCommand(command, marker)) {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
@@ -983,6 +1044,21 @@ function posixProfileAppMainPid(marker: string): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isProfileAppMainProcessCommand(command: string, marker: string): boolean {
|
||||
const executable = command.trim().split(/\s+/)[0] || "";
|
||||
if (path.basename(executable) === "open") return false;
|
||||
if (/(?:^|\s)--type=/.test(command)) return false;
|
||||
// Chromium crash handlers outlive the desktop app and include its user-data
|
||||
// directory in --database. They are helpers, not evidence of a live window.
|
||||
if (/(?:^|[\\/])(?:browser_)?crashpad_handler(?:\.exe)?(?:\s|$)/i.test(command)) return false;
|
||||
if (/\bptype=crashpad-handler\b/i.test(command)) return false;
|
||||
return normalizeProcessPath(command).includes(marker);
|
||||
}
|
||||
|
||||
export function isProfileAppMainProcessCommandForTest(command: string, userDataDir: string): boolean {
|
||||
return isProfileAppMainProcessCommand(command, normalizeProcessPath(userDataDir));
|
||||
}
|
||||
|
||||
function normalizeProcessPath(value: string): string {
|
||||
return process.platform === "win32" ? value.replace(/\\/g, "/").toLowerCase() : value;
|
||||
}
|
||||
@@ -998,7 +1074,9 @@ function windowsProfileAppMainPid(marker: string): number | undefined {
|
||||
" $_.ProcessId -ne $hostPid -and",
|
||||
" $_.CommandLine -and",
|
||||
" (($_.CommandLine -replace '\\\\', '/').ToLowerInvariant().Contains($marker)) -and",
|
||||
" ($_.CommandLine -notmatch '\\s--type=')",
|
||||
" ($_.CommandLine -notmatch '\\s--type=') -and",
|
||||
" ($_.CommandLine -notmatch '(?i)(?:browser_)?crashpad_handler(?:\\.exe)?(?:\\s|$)') -and",
|
||||
" ($_.CommandLine -notmatch '(?i)ptype=crashpad-handler')",
|
||||
"} | Sort-Object ProcessId | Select-Object -First 1 -ExpandProperty ProcessId"
|
||||
].join("\n");
|
||||
try {
|
||||
@@ -1345,6 +1423,27 @@ function startCodexAppBotWorker(config: AppConfig, profile: ReturnType<typeof fi
|
||||
});
|
||||
}
|
||||
|
||||
function startCodexAppMediaPreviewBridge(
|
||||
config: AppConfig,
|
||||
profile: ReturnType<typeof findProfileForOpen>,
|
||||
userDataDir: string
|
||||
): void {
|
||||
if (profile.agent !== "codex" || !shouldEnableCodexMediaPreviewBridge(config.mediaTools.enabled)) {
|
||||
stopCodexAppMediaPreviewBridge(profile.id);
|
||||
return;
|
||||
}
|
||||
const bridge = new CodexAppMediaPreviewBridge({
|
||||
endpoint: mediaToolsGatewayEndpoint(config),
|
||||
profileId: profile.id,
|
||||
userDataDir
|
||||
});
|
||||
const existing = codexAppMediaPreviewBridges.get(profile.id);
|
||||
if (existing?.signature === bridge.signature) return;
|
||||
existing?.bridge.stop();
|
||||
codexAppMediaPreviewBridges.set(profile.id, { bridge, signature: bridge.signature });
|
||||
bridge.start();
|
||||
}
|
||||
|
||||
function readClaudeCodeSettingsEnv(settingsFile: string): Record<string, string> {
|
||||
if (!existsSync(settingsFile)) {
|
||||
return {};
|
||||
@@ -1449,6 +1548,17 @@ function stopCodexAppBotWorker(profileId?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function stopCodexAppMediaPreviewBridge(profileId?: string): void {
|
||||
const entries = profileId
|
||||
? [[profileId, codexAppMediaPreviewBridges.get(profileId)] as const]
|
||||
: [...codexAppMediaPreviewBridges.entries()];
|
||||
for (const [id, entry] of entries) {
|
||||
if (!entry) continue;
|
||||
codexAppMediaPreviewBridges.delete(id);
|
||||
entry.bridge.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRuntimeLaunch(): { command: string; electronRunAsNode: boolean } {
|
||||
const configured = process.env.CCR_NODE_BIN?.trim();
|
||||
if (configured) {
|
||||
@@ -1485,12 +1595,27 @@ export function prepareCcrCliLauncherRuntime(): CcrCliLauncherPreparation {
|
||||
const runtimeSource = findBundledCcrCliSource();
|
||||
writeFileIfChanged(runtimeFile, readFileSync(runtimeSource, "utf8"));
|
||||
chmodSafe(runtimeFile);
|
||||
syncCcrCliCompanionRuntimes(runtimeSource, binDir);
|
||||
ensureBundledToolHubMcpRuntime(path.join(binDir, TOOL_HUB_MCP_RUNTIME_FILE_NAME));
|
||||
prependProcessPath(binDir);
|
||||
|
||||
return { binDir, persistentPathRequired };
|
||||
}
|
||||
|
||||
export function syncCcrCliCompanionRuntimes(runtimeSource: string, binDir: string): string[] {
|
||||
const sourceDir = path.dirname(runtimeSource);
|
||||
const synced: string[] = [];
|
||||
for (const fileName of CCR_CLI_COMPANION_RUNTIME_FILE_NAMES) {
|
||||
const source = path.join(sourceDir, fileName);
|
||||
if (!existsSync(source)) continue;
|
||||
const destination = path.join(binDir, fileName);
|
||||
writeFileIfChanged(destination, readFileSync(source, "utf8"));
|
||||
chmodSafe(destination);
|
||||
synced.push(destination);
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
export function persistPreparedCcrCliPath(preparation: CcrCliLauncherPreparation): void {
|
||||
if (!preparation.persistentPathRequired) {
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
GatewayProviderProbeProtocolResult,
|
||||
GatewayProviderProbeRequest,
|
||||
GatewayProviderProbeResult,
|
||||
GatewayProviderCapabilityProtocol,
|
||||
GatewayProviderProtocol
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index";
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
type ModelSource = NonNullable<GatewayProviderProbeResult["modelSource"]>;
|
||||
|
||||
type ParsedProviderUrl = ParsedProviderBaseUrl & {
|
||||
hints: GatewayProviderProtocol[];
|
||||
hints: GatewayProviderCapabilityProtocol[];
|
||||
};
|
||||
|
||||
type FetchJsonResult = {
|
||||
@@ -62,12 +63,14 @@ type ProbeCacheEntry = {
|
||||
result: GatewayProviderProbeResult;
|
||||
};
|
||||
|
||||
const protocolOrder: GatewayProviderProtocol[] = [
|
||||
const protocolOrder: GatewayProviderCapabilityProtocol[] = [
|
||||
"openai_responses",
|
||||
"openai_chat_completions",
|
||||
"anthropic_messages",
|
||||
"gemini_generate_content",
|
||||
"gemini_interactions"
|
||||
"gemini_interactions",
|
||||
"openai_image_generations",
|
||||
"openai_video_generations"
|
||||
];
|
||||
|
||||
const modelSourceOrder: ModelSource[] = ["openai", "anthropic", "gemini"];
|
||||
@@ -317,11 +320,14 @@ function mergeProviderProbeCandidateResults(
|
||||
);
|
||||
const models = uniqueStrings(results.flatMap((result) => result.probe.models));
|
||||
const protocols = results.flatMap((result) => result.probe.protocols);
|
||||
const detectedCapability = capabilities.find((capability) => capability.type === usable.probe.detectedProtocol) ?? capabilities[0];
|
||||
const detectedCapability = capabilities.find((capability) => capability.type === usable.probe.detectedProtocol)
|
||||
?? capabilities.find((capability) => isChatProtocol(capability.type));
|
||||
const probe: GatewayProviderProbeResult = {
|
||||
...usable.probe,
|
||||
capabilities,
|
||||
detectedProtocol: detectedCapability?.type ?? usable.probe.detectedProtocol,
|
||||
detectedProtocol: detectedCapability && isChatProtocol(detectedCapability.type)
|
||||
? detectedCapability.type
|
||||
: usable.probe.detectedProtocol,
|
||||
models,
|
||||
normalizedBaseUrl: detectedCapability?.baseUrl ?? usable.probe.normalizedBaseUrl,
|
||||
protocols
|
||||
@@ -358,9 +364,9 @@ function providerProbePresetCapabilities(candidate: GatewayProviderProbeCandidat
|
||||
}));
|
||||
}
|
||||
|
||||
function providerProbeCandidateBaseUrlForProtocol(baseUrl: string, protocol: GatewayProviderProtocol): string {
|
||||
function providerProbeCandidateBaseUrlForProtocol(baseUrl: string, protocol: GatewayProviderCapabilityProtocol): string {
|
||||
try {
|
||||
return providerBaseUrlForProtocol(parseProviderBaseUrl(baseUrl), protocol);
|
||||
return providerBaseUrlForCapability(parseProviderBaseUrl(baseUrl), protocol);
|
||||
} catch {
|
||||
return baseUrl.trim();
|
||||
}
|
||||
@@ -405,7 +411,7 @@ function capabilitiesFromProtocolResults(results: GatewayProviderProbeProtocolRe
|
||||
async function probeModels(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
allowedProtocols: GatewayProviderCapabilityProtocol[] = []
|
||||
): Promise<ModelProbeResult> {
|
||||
for (const source of orderedModelSources(parsed, allowedProtocols)) {
|
||||
const result = await fetchModelsForSource(parsed, source, apiKey);
|
||||
@@ -484,7 +490,7 @@ async function probeProtocols(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
models: string[],
|
||||
allowedProtocols: GatewayProviderProtocol[] = [],
|
||||
allowedProtocols: GatewayProviderCapabilityProtocol[] = [],
|
||||
mode: NonNullable<GatewayProviderProbeRequest["mode"]> = "protocols",
|
||||
providerPlugins: unknown[] = []
|
||||
): Promise<GatewayProviderProbeProtocolResult[]> {
|
||||
@@ -492,7 +498,7 @@ async function probeProtocols(
|
||||
|
||||
for (const protocol of orderedProtocols(parsed, allowedProtocols)) {
|
||||
results.push(
|
||||
mode === "connectivity"
|
||||
mode === "connectivity" && isChatProtocol(protocol)
|
||||
? await probeProtocolConnectivity(parsed, apiKey, models, protocol, providerPlugins)
|
||||
: await probeProtocolSupport(parsed, apiKey, protocol)
|
||||
);
|
||||
@@ -504,10 +510,10 @@ async function probeProtocols(
|
||||
async function probeProtocolSupport(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
protocol: GatewayProviderProtocol
|
||||
protocol: GatewayProviderCapabilityProtocol
|
||||
): Promise<GatewayProviderProbeProtocolResult> {
|
||||
const endpoints = endpointsForProtocol(parsed, protocol, undefined);
|
||||
const endpoint = endpoints[0]?.endpoint ?? providerBaseUrlForProtocol(parsed, protocol);
|
||||
const endpoint = endpoints[0]?.endpoint ?? providerBaseUrlForCapability(parsed, protocol);
|
||||
let firstResult: GatewayProviderProbeProtocolResult | undefined;
|
||||
|
||||
for (const candidate of endpoints) {
|
||||
@@ -542,12 +548,12 @@ async function probeProtocolConnectivity(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
models: string[],
|
||||
protocol: GatewayProviderProtocol,
|
||||
protocol: GatewayProviderCapabilityProtocol,
|
||||
providerPlugins: unknown[] = []
|
||||
): Promise<GatewayProviderProbeProtocolResult> {
|
||||
const model = pickProbeModel(models, protocol);
|
||||
const endpoints = endpointsForProtocol(parsed, protocol, model);
|
||||
const endpoint = endpoints[0]?.endpoint ?? providerBaseUrlForProtocol(parsed, protocol);
|
||||
const endpoint = endpoints[0]?.endpoint ?? providerBaseUrlForCapability(parsed, protocol);
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
@@ -593,7 +599,7 @@ async function probeProtocolConnectivity(
|
||||
};
|
||||
}
|
||||
|
||||
function requestForProtocol(protocol: GatewayProviderProtocol, model: string, apiKey: string | undefined): RequestInit {
|
||||
function requestForProtocol(protocol: GatewayProviderCapabilityProtocol, model: string, apiKey: string | undefined): RequestInit {
|
||||
if (protocol === "openai_responses") {
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
@@ -675,9 +681,9 @@ function requestForProtocol(protocol: GatewayProviderProtocol, model: string, ap
|
||||
};
|
||||
}
|
||||
|
||||
function requestForProtocolSupport(protocol: GatewayProviderProtocol, apiKey: string | undefined): RequestInit {
|
||||
function requestForProtocolSupport(protocol: GatewayProviderCapabilityProtocol, apiKey: string | undefined): RequestInit {
|
||||
return {
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify(mediaProbeBody(protocol)),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headersForProtocol(protocol, apiKey)
|
||||
@@ -686,6 +692,24 @@ function requestForProtocolSupport(protocol: GatewayProviderProtocol, apiKey: st
|
||||
};
|
||||
}
|
||||
|
||||
function mediaProbeBody(protocol: GatewayProviderCapabilityProtocol): Record<string, unknown> {
|
||||
if (protocol === "openai_image_generations") {
|
||||
return {
|
||||
model: "__ccr_media_protocol_probe__",
|
||||
n: 0,
|
||||
prompt: ""
|
||||
};
|
||||
}
|
||||
if (protocol === "openai_video_generations") {
|
||||
return {
|
||||
duration: 0,
|
||||
model: "__ccr_media_protocol_probe__",
|
||||
prompt: ""
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function providerProbeAuthRequest(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
@@ -791,7 +815,7 @@ function parseProviderUrl(value: string): ParsedProviderUrl {
|
||||
|
||||
function endpointsForProtocol(
|
||||
parsed: ParsedProviderUrl,
|
||||
protocol: GatewayProviderProtocol,
|
||||
protocol: GatewayProviderCapabilityProtocol,
|
||||
model: string | undefined
|
||||
): ProtocolEndpoint[] {
|
||||
if (protocol === "openai_responses") {
|
||||
@@ -834,6 +858,20 @@ function endpointsForProtocol(
|
||||
];
|
||||
}
|
||||
|
||||
if (protocol === "openai_image_generations") {
|
||||
return parsed.openaiBaseUrlCandidates.map((baseUrl) => ({
|
||||
baseUrl,
|
||||
endpoint: `${baseUrl}/images/generations`
|
||||
}));
|
||||
}
|
||||
|
||||
if (protocol === "openai_video_generations") {
|
||||
return parsed.openaiBaseUrlCandidates.map((baseUrl) => ({
|
||||
baseUrl,
|
||||
endpoint: `${baseUrl}/videos/generations`
|
||||
}));
|
||||
}
|
||||
|
||||
const encodedModel = encodeURIComponent(stripGeminiModelPrefix(model || "model"));
|
||||
return [
|
||||
{
|
||||
@@ -884,7 +922,7 @@ function geminiHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
: {};
|
||||
}
|
||||
|
||||
function headersForProtocol(protocol: GatewayProviderProtocol, apiKey: string | undefined): Record<string, string> {
|
||||
function headersForProtocol(protocol: GatewayProviderCapabilityProtocol, apiKey: string | undefined): Record<string, string> {
|
||||
if (protocol === "anthropic_messages") {
|
||||
return anthropicHeaders(apiKey);
|
||||
}
|
||||
@@ -969,7 +1007,7 @@ function stripGeminiModelPrefix(value: string): string {
|
||||
return value.replace(/^models\//i, "");
|
||||
}
|
||||
|
||||
function pickProbeModel(models: string[], protocol: GatewayProviderProtocol): string | undefined {
|
||||
function pickProbeModel(models: string[], protocol: GatewayProviderCapabilityProtocol): string | undefined {
|
||||
const candidates = uniqueStrings(models);
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
@@ -993,8 +1031,8 @@ function pickProbeModel(models: string[], protocol: GatewayProviderProtocol): st
|
||||
|
||||
function orderedProtocols(
|
||||
parsed: ParsedProviderUrl,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
): GatewayProviderProtocol[] {
|
||||
allowedProtocols: GatewayProviderCapabilityProtocol[] = []
|
||||
): GatewayProviderCapabilityProtocol[] {
|
||||
const ordered = uniqueProtocols([...parsed.hints, ...protocolOrder]);
|
||||
if (allowedProtocols.length === 0) {
|
||||
return ordered;
|
||||
@@ -1005,7 +1043,7 @@ function orderedProtocols(
|
||||
|
||||
function orderedModelSources(
|
||||
parsed: ParsedProviderUrl,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
allowedProtocols: GatewayProviderCapabilityProtocol[] = []
|
||||
): ModelSource[] {
|
||||
const allowedSources = allowedProtocols.length > 0
|
||||
? new Set(allowedProtocols.map(protocolModelSource))
|
||||
@@ -1020,7 +1058,7 @@ function orderedModelSources(
|
||||
return ordered.filter((source) => allowedSources.has(source));
|
||||
}
|
||||
|
||||
function protocolModelSource(protocol: GatewayProviderProtocol): ModelSource {
|
||||
function protocolModelSource(protocol: GatewayProviderCapabilityProtocol): ModelSource {
|
||||
if (protocol === "anthropic_messages") {
|
||||
return "anthropic";
|
||||
}
|
||||
@@ -1030,15 +1068,25 @@ function protocolModelSource(protocol: GatewayProviderProtocol): ModelSource {
|
||||
return "openai";
|
||||
}
|
||||
|
||||
function orderedProtocolFallback(allowedProtocols: GatewayProviderProtocol[] = []): GatewayProviderProtocol | undefined {
|
||||
if (allowedProtocols.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const allowed = new Set(allowedProtocols);
|
||||
return protocolOrder.find((protocol) => allowed.has(protocol)) ?? allowedProtocols[0];
|
||||
function isChatProtocol(protocol: GatewayProviderCapabilityProtocol): protocol is GatewayProviderProtocol {
|
||||
return protocol !== "openai_image_generations" && protocol !== "openai_video_generations";
|
||||
}
|
||||
|
||||
function protocolIsAllowed(protocol: GatewayProviderProtocol, allowedProtocols: GatewayProviderProtocol[]): boolean {
|
||||
function isMediaProtocol(protocol: GatewayProviderCapabilityProtocol): boolean {
|
||||
return !isChatProtocol(protocol);
|
||||
}
|
||||
|
||||
function orderedProtocolFallback(allowedProtocols: GatewayProviderCapabilityProtocol[] = []): GatewayProviderProtocol | undefined {
|
||||
const chatProtocols = allowedProtocols.filter(isChatProtocol);
|
||||
if (chatProtocols.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const allowed = new Set(chatProtocols);
|
||||
return protocolOrder.find((protocol): protocol is GatewayProviderProtocol => isChatProtocol(protocol) && allowed.has(protocol))
|
||||
?? chatProtocols[0];
|
||||
}
|
||||
|
||||
function protocolIsAllowed(protocol: GatewayProviderProtocol, allowedProtocols: GatewayProviderCapabilityProtocol[]): boolean {
|
||||
return allowedProtocols.length === 0 || allowedProtocols.includes(protocol);
|
||||
}
|
||||
|
||||
@@ -1046,14 +1094,16 @@ function detectProtocol(
|
||||
parsed: ParsedProviderUrl,
|
||||
protocols: GatewayProviderProbeProtocolResult[],
|
||||
modelSource: ModelSource | undefined,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
allowedProtocols: GatewayProviderCapabilityProtocol[] = []
|
||||
): GatewayProviderProtocol | undefined {
|
||||
const supported = protocols.find((item) => item.supported);
|
||||
const supported = protocols.find((item) => item.supported && isChatProtocol(item.protocol));
|
||||
if (supported) {
|
||||
return supported.protocol;
|
||||
return supported.protocol as GatewayProviderProtocol;
|
||||
}
|
||||
|
||||
const hinted = parsed.hints.find((protocol) => protocolIsAllowed(protocol, allowedProtocols));
|
||||
const hinted = parsed.hints.find((protocol): protocol is GatewayProviderProtocol =>
|
||||
isChatProtocol(protocol) && protocolIsAllowed(protocol, allowedProtocols)
|
||||
);
|
||||
if (hinted) {
|
||||
return hinted;
|
||||
}
|
||||
@@ -1105,9 +1155,9 @@ function resolveProbeBaseUrl(
|
||||
return providerBaseUrlForProtocol(parsed, protocol);
|
||||
}
|
||||
|
||||
function protocolHints(value: string): GatewayProviderProtocol[] {
|
||||
function protocolHints(value: string): GatewayProviderCapabilityProtocol[] {
|
||||
const normalized = value.toLowerCase();
|
||||
const hints: GatewayProviderProtocol[] = [];
|
||||
const hints: GatewayProviderCapabilityProtocol[] = [];
|
||||
|
||||
if (normalized.includes("chat/completions")) {
|
||||
hints.push("openai_chat_completions");
|
||||
@@ -1130,6 +1180,12 @@ function protocolHints(value: string): GatewayProviderProtocol[] {
|
||||
if (normalized.includes("generativelanguage.googleapis.com")) {
|
||||
hints.push("gemini_interactions");
|
||||
}
|
||||
if (normalized.includes("images/generations")) {
|
||||
hints.push("openai_image_generations");
|
||||
}
|
||||
if (normalized.includes("videos/generations")) {
|
||||
hints.push("openai_video_generations");
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
@@ -1137,7 +1193,7 @@ function protocolHints(value: string): GatewayProviderProtocol[] {
|
||||
function isProtocolSupported(
|
||||
status: number | undefined,
|
||||
message: string,
|
||||
protocol?: GatewayProviderProtocol
|
||||
protocol?: GatewayProviderCapabilityProtocol
|
||||
): boolean {
|
||||
if (status === undefined) {
|
||||
return false;
|
||||
@@ -1151,7 +1207,7 @@ function isProtocolSupported(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 400) {
|
||||
if (status === 400 || status === 422) {
|
||||
const normalized = message.toLowerCase();
|
||||
if (/not found|unknown endpoint|unknown route|no route/.test(normalized)) {
|
||||
return false;
|
||||
@@ -1165,8 +1221,8 @@ function isProtocolSupported(
|
||||
export function isProviderProtocolEndpointSupportedForProbe(
|
||||
status: number | undefined,
|
||||
message: string,
|
||||
protocol: GatewayProviderProtocol,
|
||||
hints: GatewayProviderProtocol[] = []
|
||||
protocol: GatewayProviderCapabilityProtocol,
|
||||
hints: GatewayProviderCapabilityProtocol[] = []
|
||||
): boolean {
|
||||
if (isProtocolSupported(status, message, protocol)) {
|
||||
return true;
|
||||
@@ -1174,14 +1230,17 @@ export function isProviderProtocolEndpointSupportedForProbe(
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
const normalized = message.toLowerCase();
|
||||
return (hints.length === 0 || protocolMatchesHints(protocol, hints)) &&
|
||||
const hintMatches = isMediaProtocol(protocol)
|
||||
? status === 401 || hints.includes(protocol)
|
||||
: hints.length === 0 || protocolMatchesHints(protocol, hints);
|
||||
return hintMatches &&
|
||||
!/not found|unknown endpoint|unknown route|no route/.test(normalized);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function protocolMatchesHints(protocol: GatewayProviderProtocol, hints: GatewayProviderProtocol[]): boolean {
|
||||
function protocolMatchesHints(protocol: GatewayProviderCapabilityProtocol, hints: GatewayProviderCapabilityProtocol[]): boolean {
|
||||
if (hints.includes(protocol)) {
|
||||
return true;
|
||||
}
|
||||
@@ -1260,10 +1319,17 @@ function uniqueStrings(values: string[]): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function uniqueProtocols(values: GatewayProviderProtocol[]): GatewayProviderProtocol[] {
|
||||
function uniqueProtocols(values: GatewayProviderCapabilityProtocol[]): GatewayProviderCapabilityProtocol[] {
|
||||
return values.filter((value, index) => values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function providerBaseUrlForCapability(
|
||||
parsed: ParsedProviderBaseUrl,
|
||||
protocol: GatewayProviderCapabilityProtocol
|
||||
): string {
|
||||
return isChatProtocol(protocol) ? providerBaseUrlForProtocol(parsed, protocol) : parsed.openaiBaseUrl;
|
||||
}
|
||||
|
||||
function uniqueProtocolEndpoints(values: ProtocolEndpoint[]): ProtocolEndpoint[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ProtocolEndpoint[] = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary.
|
||||
*/
|
||||
import type { AppConfig, GatewayProviderCapability, GatewayProviderConfig, GatewayProviderProtocol, ProviderCredentialConfig } from "@ccr/core/contracts/app";
|
||||
import type { AppConfig, GatewayProviderCapability, GatewayProviderCapabilityProtocol, GatewayProviderConfig, GatewayProviderProtocol, ProviderCredentialConfig } from "@ccr/core/contracts/app";
|
||||
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index";
|
||||
import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "@ccr/core/providers/url";
|
||||
import { modelRegistryForConfig, parseProviderModelSelector, providerRuntimeId } from "@ccr/core/routing/model-registry";
|
||||
@@ -10,10 +10,12 @@ import { gatewayProviderProtocolFallbackOrder, type CoreGatewayProvider } from "
|
||||
export function providerCapabilityForClientProtocol(
|
||||
provider: GatewayProviderConfig,
|
||||
clientProtocol: GatewayProviderProtocol
|
||||
): GatewayProviderCapability | undefined {
|
||||
): (GatewayProviderCapability & { type: GatewayProviderProtocol }) | undefined {
|
||||
const capabilities = normalizedProviderCapabilities(provider);
|
||||
for (const protocol of providerProtocolPreferenceForClient(clientProtocol)) {
|
||||
const capability = capabilities.find((item) => item.type === protocol);
|
||||
const capability = capabilities.find(
|
||||
(item): item is GatewayProviderCapability & { type: GatewayProviderProtocol } => item.type === protocol
|
||||
);
|
||||
if (capability) {
|
||||
return capability;
|
||||
}
|
||||
@@ -173,9 +175,9 @@ export function sortProviderCredentialsForConfig(credentials: ProviderCredential
|
||||
export function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] {
|
||||
const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : [];
|
||||
const normalized: GatewayProviderCapability[] = [];
|
||||
const byProtocol = new Map<GatewayProviderProtocol, GatewayProviderCapability>();
|
||||
const byProtocol = new Map<GatewayProviderCapabilityProtocol, GatewayProviderCapability>();
|
||||
for (const capability of capabilities) {
|
||||
const type = normalizeProviderProtocol(capability.type);
|
||||
const type = normalizeProviderCapabilityProtocol(capability.type);
|
||||
const baseUrl = capability.baseUrl?.trim();
|
||||
if (!type || !baseUrl) {
|
||||
continue;
|
||||
@@ -191,7 +193,7 @@ export function normalizedProviderCapabilities(provider: GatewayProviderConfig):
|
||||
}
|
||||
}
|
||||
for (const capability of capabilities) {
|
||||
const type = normalizeProviderProtocol(capability.type);
|
||||
const type = normalizeProviderCapabilityProtocol(capability.type);
|
||||
const selected = type ? byProtocol.get(type) : undefined;
|
||||
if (selected && !normalized.includes(selected)) {
|
||||
normalized.push(selected);
|
||||
@@ -211,7 +213,10 @@ function applyPresetProtocolLock(
|
||||
}
|
||||
|
||||
const lockedProtocolSet = new Set(lockedProtocols);
|
||||
const lockedCapabilities = capabilities.filter((capability) => lockedProtocolSet.has(capability.type));
|
||||
const lockedCapabilities = capabilities.filter((capability) => {
|
||||
const protocol = normalizeProviderProtocol(capability.type);
|
||||
return Boolean(protocol && lockedProtocolSet.has(protocol));
|
||||
});
|
||||
if (lockedCapabilities.length > 0) {
|
||||
return lockedCapabilities;
|
||||
}
|
||||
@@ -255,17 +260,17 @@ function providerCapabilityPriority(capability: GatewayProviderCapability): numb
|
||||
}
|
||||
|
||||
|
||||
export function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string {
|
||||
export function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderCapabilityProtocol): string {
|
||||
return `${providerRuntimeId(provider)}::${protocol}`;
|
||||
}
|
||||
|
||||
|
||||
function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderProtocol): string {
|
||||
function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderCapabilityProtocol): string {
|
||||
return `${providerName}::${protocol}`;
|
||||
}
|
||||
|
||||
|
||||
export function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol, value: string): boolean {
|
||||
export function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderCapabilityProtocol, value: string): boolean {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return providerCapabilityInternalName(provider, protocol).toLowerCase() === normalized ||
|
||||
providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized;
|
||||
@@ -289,7 +294,7 @@ export function sanitizeHeaderValue(value: unknown): string {
|
||||
|
||||
export function providerCredentialInternalName(
|
||||
provider: GatewayProviderConfig,
|
||||
protocol: GatewayProviderProtocol,
|
||||
protocol: GatewayProviderCapabilityProtocol,
|
||||
credential: ProviderCredentialConfig
|
||||
): string {
|
||||
return `${providerCapabilityInternalName(provider, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`;
|
||||
@@ -299,7 +304,7 @@ export function providerCredentialInternalName(
|
||||
export function parseProviderCredentialInternalName(value: string | undefined): {
|
||||
credentialSlug: string;
|
||||
providerId: string;
|
||||
protocol: GatewayProviderProtocol;
|
||||
protocol: GatewayProviderCapabilityProtocol;
|
||||
} | undefined {
|
||||
const marker = "::cred:";
|
||||
const markerIndex = value?.lastIndexOf(marker) ?? -1;
|
||||
@@ -312,7 +317,7 @@ export function parseProviderCredentialInternalName(value: string | undefined):
|
||||
if (!credentialSlug || protocolSeparator <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2));
|
||||
const protocol = normalizeProviderCapabilityProtocol(baseName.slice(protocolSeparator + 2));
|
||||
const providerId = baseName.slice(0, protocolSeparator).trim();
|
||||
return protocol && providerId ? { credentialSlug, providerId, protocol } : undefined;
|
||||
}
|
||||
@@ -404,6 +409,24 @@ export function normalizeProviderProtocol(value: unknown): GatewayProviderProtoc
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeProviderCapabilityProtocol(value: unknown): GatewayProviderCapabilityProtocol | undefined {
|
||||
const chatProtocol = normalizeProviderProtocol(value);
|
||||
if (chatProtocol) {
|
||||
return chatProtocol;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "openai_image_generations" || normalized === "openai_images") {
|
||||
return "openai_image_generations";
|
||||
}
|
||||
if (normalized === "openai_video_generations" || normalized === "openai_videos") {
|
||||
return "openai_video_generations";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
export function inferProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol {
|
||||
const url = readBaseUrl(provider)?.toLowerCase() ?? "";
|
||||
@@ -434,7 +457,7 @@ export function resolveResponseProviderProtocol(headers: Headers, config: AppCon
|
||||
}
|
||||
const credentialInternalName = parseProviderCredentialInternalName(providerName);
|
||||
if (credentialInternalName) {
|
||||
return credentialInternalName.protocol;
|
||||
return normalizeProviderProtocol(credentialInternalName.protocol);
|
||||
}
|
||||
const provider = config ? findProviderByPublicOrInternalName(config, providerName) : undefined;
|
||||
if (!provider) {
|
||||
@@ -443,8 +466,8 @@ export function resolveResponseProviderProtocol(headers: Headers, config: AppCon
|
||||
const capability = normalizedProviderCapabilities(provider).find((item) =>
|
||||
providerCapabilityNameMatches(provider, item.type, providerName)
|
||||
);
|
||||
if (capability) {
|
||||
return capability.type;
|
||||
if (capability && normalizeProviderProtocol(capability.type)) {
|
||||
return normalizeProviderProtocol(capability.type);
|
||||
}
|
||||
return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider);
|
||||
}
|
||||
@@ -477,11 +500,13 @@ function providerMatchesName(provider: GatewayProviderConfig, name: string): boo
|
||||
}
|
||||
|
||||
|
||||
function normalizeProviderRuntimeBaseUrl(value: string | undefined, type: GatewayProviderProtocol): string | undefined {
|
||||
function normalizeProviderRuntimeBaseUrl(value: string | undefined, type: GatewayProviderCapabilityProtocol): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeProviderBaseUrlInput(value, type) || undefined;
|
||||
return normalizeProviderProtocol(type)
|
||||
? normalizeProviderBaseUrlInput(value, normalizeProviderProtocol(type)) || undefined
|
||||
: normalizeProviderBaseUrlInput(value) || undefined;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -228,7 +228,9 @@ const providerInternalProtocols = new Set([
|
||||
"gemini_generate_content",
|
||||
"gemini_interactions",
|
||||
"openai_chat_completions",
|
||||
"openai_responses"
|
||||
"openai_image_generations",
|
||||
"openai_responses",
|
||||
"openai_video_generations"
|
||||
]);
|
||||
|
||||
function sanitizeProviderHeaderId(value: string | undefined): string | undefined {
|
||||
|
||||
@@ -79,7 +79,11 @@ test("gateway config rewrites Fusion fixed base and vision models to core provid
|
||||
profile.metadata.fusionVision.modelSelector,
|
||||
/^provider-zhipu-ai-china---coding-plan-[a-f0-9]{10}::openai_chat_completions::cred:test-1\/glm-5v-turbo$/
|
||||
);
|
||||
assert.equal(profile.execution.maxToolCalls, Number.MAX_SAFE_INTEGER);
|
||||
assert.equal(profile.execution.maxTurns, Number.MAX_SAFE_INTEGER);
|
||||
assert.equal(profiles[0].baseModel.fixedModel, `${providerName}/glm-5.2`);
|
||||
assert.equal(profiles[0].execution.maxToolCalls, 8);
|
||||
assert.equal(profiles[0].execution.maxTurns, 6);
|
||||
});
|
||||
|
||||
test("issue 1480 Fusion vision config injects core auth token into MCP gateway runtime", async () => {
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { normalizeGrokProviderMediaCapabilities } from "@ccr/core/agents/local-providers/grok.ts";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { GatewayMediaExecutor } from "@ccr/core/media/executors.ts";
|
||||
import { MediaService, mediaServiceForTest, resolveProviderMediaTarget } from "@ccr/core/media/service.ts";
|
||||
import { MEDIA_ARTIFACT_PATH_PREFIX, handleMediaArtifactRequest, handleMediaToolsMcpRequest } from "@ccr/core/mcp/grok-media-mcp.ts";
|
||||
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from("ccr-grok-media-test")
|
||||
]);
|
||||
const mp4 = Buffer.concat([
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x18]),
|
||||
Buffer.from("ftyp"),
|
||||
Buffer.from("mp42ccr-grok-media-test")
|
||||
]);
|
||||
|
||||
test("media tools bind profile-specific runtime names to gateway media models", async (t) => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-media-bindings-"));
|
||||
const config = mediaConfig("https://media.example");
|
||||
const service = new MediaService(root);
|
||||
service.start(config, "http://127.0.0.1:3456");
|
||||
t.after(async () => {
|
||||
await service.stop();
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
assert.deepEqual(service.toolBindings(), [
|
||||
{ modelSelector: "Media Provider/grok-imagine-image-quality", name: "image_generate_test", operation: "image-generate" },
|
||||
{ modelSelector: "Media Provider/grok-imagine-image-quality", name: "image_edit_test", operation: "image-edit" },
|
||||
{ modelSelector: "Media Provider/grok-imagine-video", name: "video_generate_test", operation: "video-generate" },
|
||||
{ modelSelector: "Media Provider/grok-imagine-video", name: "media_job_get_test", operation: "job-get" },
|
||||
{ modelSelector: "Media Provider/grok-imagine-video", name: "media_job_cancel_test", operation: "job-cancel" }
|
||||
]);
|
||||
const target = resolveProviderMediaTarget(config, "Media Provider/grok-imagine-image-quality");
|
||||
assert.deepEqual({ model: target.model, providerName: target.providerName }, {
|
||||
model: "grok-imagine-image-quality",
|
||||
providerName: "Media Provider"
|
||||
});
|
||||
assert.match(target.providerSelector, /::openai_image_generations::cred:/);
|
||||
assert.equal(target.providerBaseUrl, "https://media.example/v1");
|
||||
});
|
||||
|
||||
test("media artifact downloads reject private-network URLs from public providers", async () => {
|
||||
const executor = new GatewayMediaExecutor({
|
||||
model: "image-model",
|
||||
providerBaseUrl: "https://8.8.8.8/v1",
|
||||
providerName: "Public Media Provider",
|
||||
providerSelector: "public-media::openai_image_generations"
|
||||
}, { baseUrl: "http://127.0.0.1:3457" });
|
||||
|
||||
await assert.rejects(
|
||||
executor.download({ remoteUrl: "http://127.0.0.1/private.png" }, new AbortController().signal),
|
||||
/private or non-public address/
|
||||
);
|
||||
|
||||
const localExecutor = new GatewayMediaExecutor({
|
||||
model: "image-model",
|
||||
providerBaseUrl: "http://127.0.0.1:3000/v1",
|
||||
providerName: "Local Media Provider",
|
||||
providerSelector: "local-media::openai_image_generations"
|
||||
}, { baseUrl: "http://127.0.0.1:3457" });
|
||||
await assert.rejects(
|
||||
localExecutor.download({ remoteUrl: "http://127.0.0.1:3001/private.png" }, new AbortController().signal),
|
||||
/outside the configured provider origin/
|
||||
);
|
||||
});
|
||||
|
||||
test("implicit media input roots reject the filesystem root and home directory", () => {
|
||||
const home = path.resolve(os.homedir());
|
||||
assert.equal(mediaServiceForTest.isSafeImplicitWorkingDirectory(path.parse(home).root, home), false);
|
||||
assert.equal(mediaServiceForTest.isSafeImplicitWorkingDirectory(home, home), false);
|
||||
assert.equal(mediaServiceForTest.isSafeImplicitWorkingDirectory(path.join(home, "workspace", "project"), home), true);
|
||||
});
|
||||
|
||||
test("an imported Grok Agent supplies OAuth-backed Grok API media models without an API key", async (t) => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-agent-media-"));
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.mediaTools.enabled = true;
|
||||
config.Providers = [normalizeGrokProviderMediaCapabilities({
|
||||
apiKey: "ccr-local-agent-login",
|
||||
baseUrl: "https://cli-chat-proxy.grok.com/v1",
|
||||
models: ["grok-4.5"],
|
||||
name: "Imported Grok"
|
||||
})];
|
||||
config.virtualModelProfiles = [{
|
||||
baseModel: { fixedModel: "Imported Grok/grok-4.5", mode: "fixed" },
|
||||
displayName: "Legacy Grok Media",
|
||||
enabled: true,
|
||||
id: "legacy-grok-media",
|
||||
key: "legacy-grok-media",
|
||||
match: { exactAliases: ["legacy-grok-media"], prefixes: [], suffixes: [] },
|
||||
metadata: {
|
||||
fusionMedia: {
|
||||
imageGenerateToolName: "image_generate_imported",
|
||||
imageModelSelector: "grok-cli",
|
||||
videoModelSelector: "grok-cli",
|
||||
videoStartToolName: "video_generate_imported"
|
||||
}
|
||||
},
|
||||
tools: []
|
||||
}];
|
||||
const service = new MediaService(root);
|
||||
service.start(config, "http://127.0.0.1:3456");
|
||||
t.after(async () => {
|
||||
await service.stop();
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
assert.deepEqual(service.toolBindings(), [
|
||||
{ modelSelector: "Imported Grok/grok-imagine-image-quality", name: "image_generate_imported", operation: "image-generate" },
|
||||
{ modelSelector: "Imported Grok/grok-imagine-video", name: "video_generate_imported", operation: "video-generate" }
|
||||
]);
|
||||
const target = resolveProviderMediaTarget(config, "Imported Grok/grok-imagine-image-quality", "image-generate");
|
||||
assert.equal(target.model, "grok-imagine-image-quality");
|
||||
assert.equal(target.providerName, "Imported Grok");
|
||||
assert.match(target.providerSelector, /::openai_image_generations$/);
|
||||
});
|
||||
|
||||
test("provider image jobs use the internal media gateway, persist artifacts, and remain idempotent", async (t) => {
|
||||
const requests = [];
|
||||
let imageGenerateBody;
|
||||
let imageEditBody;
|
||||
let service;
|
||||
const server = createServer(async (request, response) => {
|
||||
requests.push({
|
||||
coreAuth: request.headers["x-ccr-core-auth"],
|
||||
method: request.method,
|
||||
targetProvider: request.headers["x-target-provider"],
|
||||
url: request.url
|
||||
});
|
||||
if (request.method === "POST" && request.url === "/v1/images/generations") {
|
||||
imageGenerateBody = JSON.parse((await consume(request)).toString("utf8"));
|
||||
json(response, { data: [{ url: `${baseUrl(server)}/artifact.png` }], usage: { cost_in_usd_ticks: 200000000 } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && request.url === "/v1/images/edits") {
|
||||
imageEditBody = JSON.parse((await consume(request)).toString("utf8"));
|
||||
json(response, { data: [{ url: `${baseUrl(server)}/artifact.png` }], usage: { cost_in_usd_ticks: 300000000 } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && request.url === "/artifact.png") {
|
||||
response.writeHead(200, { "content-length": png.length, "content-type": "image/png" });
|
||||
response.end(png);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/mcp") {
|
||||
await handleMediaToolsMcpRequest(request, response, service);
|
||||
return;
|
||||
}
|
||||
const requestUrl = new URL(request.url, baseUrl(server));
|
||||
if (requestUrl.pathname.startsWith(MEDIA_ARTIFACT_PATH_PREFIX)) {
|
||||
handleMediaArtifactRequest(request, response, requestUrl, service);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
if (!await listenOrSkip(t, server)) return;
|
||||
t.after(() => server.close());
|
||||
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-media-image-"));
|
||||
service = new MediaService(root);
|
||||
t.after(async () => {
|
||||
await service.stop();
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
const config = mediaConfig(baseUrl(server));
|
||||
service.start(config, baseUrl(server), {
|
||||
authHeader: "x-ccr-core-auth",
|
||||
authToken: "core-test-token",
|
||||
baseUrl: baseUrl(server)
|
||||
});
|
||||
|
||||
const listResponse = await fetch(`${baseUrl(server)}/mcp`, {
|
||||
body: JSON.stringify({ id: 1, jsonrpc: "2.0", method: "tools/list" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST"
|
||||
});
|
||||
const listPayload = await listResponse.json();
|
||||
assert.deepEqual(listPayload.result.tools.map((tool) => tool.name), [
|
||||
"image_generate_test",
|
||||
"image_edit_test",
|
||||
"video_generate_test",
|
||||
"media_job_get_test",
|
||||
"media_job_cancel_test"
|
||||
]);
|
||||
|
||||
const callResponse = await fetch(`${baseUrl(server)}/mcp`, {
|
||||
body: JSON.stringify({
|
||||
id: 2,
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: { arguments: { idempotency_key: "same-paid-request", prompt: "A blue cup" }, name: "image_generate_test" }
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST"
|
||||
});
|
||||
const callPayload = await callResponse.json();
|
||||
const first = JSON.parse(callPayload.result.content[0].text);
|
||||
const second = await service.imageGenerate({ idempotency_key: "same-paid-request", prompt: "A blue cup" }, "Media Provider/grok-imagine-image-quality");
|
||||
assert.equal(first.status, "succeeded");
|
||||
assert.equal(second.id, first.id);
|
||||
assert.equal(first.artifact.mimeType, "image/png");
|
||||
assert.equal(first.usage.costUsdTicks, 200000000);
|
||||
assert.equal(requests.filter((item) => item.url === "/v1/images/generations").length, 1);
|
||||
assert.equal(requests.find((item) => item.url === "/v1/images/generations").coreAuth, "core-test-token");
|
||||
assert.match(requests.find((item) => item.url === "/v1/images/generations").targetProvider, /::openai_image_generations::cred:/);
|
||||
assert.equal(imageGenerateBody.provider_option, undefined);
|
||||
assert.equal(imageGenerateBody.model, "grok-imagine-image-quality");
|
||||
|
||||
const referenceOne = path.join(root, "reference-one.png");
|
||||
const referenceTwo = path.join(root, "reference-two.png");
|
||||
writeFileSync(referenceOne, png);
|
||||
writeFileSync(referenceTwo, png);
|
||||
const edited = await service.imageEdit({ images: [referenceOne, referenceTwo], prompt: "Combine both references" }, "Media Provider/grok-imagine-image-quality");
|
||||
assert.equal(edited.status, "succeeded");
|
||||
assert.equal(edited.usage.costUsdTicks, 300000000);
|
||||
assert.equal(imageEditBody.image, undefined);
|
||||
assert.equal(imageEditBody.images.length, 2);
|
||||
assert.ok(imageEditBody.images.every((image) => image.type === "image_url" && image.url.startsWith("data:image/png;base64,")));
|
||||
|
||||
const artifactUrl = new URL(first.artifact.url);
|
||||
const resolved = service.resolveArtifact(first.artifact.id, artifactUrl.searchParams.get("token"));
|
||||
assert.equal(resolved.state, "ok");
|
||||
assert.ok(existsSync(resolved.artifact.localPath));
|
||||
assert.deepEqual(readFileSync(resolved.artifact.localPath), png);
|
||||
const rangeResponse = await fetch(first.artifact.url, { headers: { range: "bytes=0-7" } });
|
||||
assert.equal(rangeResponse.status, 206);
|
||||
assert.equal(rangeResponse.headers.get("content-range"), `bytes 0-7/${png.length}`);
|
||||
assert.equal(
|
||||
rangeResponse.headers.get("content-security-policy"),
|
||||
"default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'"
|
||||
);
|
||||
assert.deepEqual(Buffer.from(await rangeResponse.arrayBuffer()), png.subarray(0, 8));
|
||||
|
||||
const reloaded = new MediaService(root);
|
||||
assert.equal(reloaded.getJob(first.id).status, "succeeded");
|
||||
});
|
||||
|
||||
test("provider video jobs return immediately and finish through asynchronous polling", async (t) => {
|
||||
let expectedReferenceCount = 0;
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method === "POST" && request.url === "/v1/videos/generations") {
|
||||
const body = JSON.parse((await consume(request)).toString("utf8"));
|
||||
assert.equal(body.prompt, "Animate the product");
|
||||
assert.equal(body.image, undefined);
|
||||
assert.equal(body.reference_images.length, expectedReferenceCount);
|
||||
json(response, { request_id: "video-request-1" });
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && request.url === "/v1/videos/video-request-1") {
|
||||
assert.equal(request.headers["x-target-model"], "grok-imagine-video");
|
||||
json(response, { status: "done", usage: { cost_in_usd_ticks: 500000000 }, video: { url: `${baseUrl(server)}/artifact.mp4` } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && request.url === "/artifact.mp4") {
|
||||
response.writeHead(200, { "content-length": mp4.length, "content-type": "video/mp4" });
|
||||
response.end(mp4);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
if (!await listenOrSkip(t, server)) return;
|
||||
t.after(() => server.close());
|
||||
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-media-video-"));
|
||||
const service = new MediaService(root);
|
||||
t.after(async () => {
|
||||
await service.stop();
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
service.start(mediaConfig(baseUrl(server)), "http://127.0.0.1:3456", { baseUrl: baseUrl(server) });
|
||||
|
||||
const references = Array.from({ length: 7 }, (_, index) => {
|
||||
const file = path.join(root, `reference-${index}.png`);
|
||||
writeFileSync(file, png);
|
||||
return file;
|
||||
});
|
||||
expectedReferenceCount = references.length;
|
||||
const started = service.videoStart({ duration: 6, images: references, prompt: "Animate the product", resolution: "480p" }, "Media Provider/grok-imagine-video");
|
||||
assert.ok(started.status === "queued" || started.status === "running");
|
||||
const completed = await waitForJob(service, started.id);
|
||||
assert.equal(completed.status, "succeeded");
|
||||
assert.equal(completed.remoteRequestId, "video-request-1");
|
||||
assert.equal(completed.artifact.mimeType, "video/mp4");
|
||||
assert.equal(completed.usage.costUsdTicks, 500000000);
|
||||
assert.deepEqual(readFileSync(completed.artifact.localPath), mp4);
|
||||
});
|
||||
|
||||
function mediaConfig(baseUrlValue) {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.mediaTools = {
|
||||
...config.mediaTools,
|
||||
artifactTtlHours: 1,
|
||||
enabled: true,
|
||||
jobTimeoutMs: 10000
|
||||
};
|
||||
config.Providers = [{
|
||||
apikey: "legacy-provider-key",
|
||||
baseUrl: `${baseUrlValue}/v1`,
|
||||
credentials: [
|
||||
{ apiKey: "provider-secondary-key", enabled: true, priority: 2 },
|
||||
{ api_key: "provider-primary-key", enabled: true, priority: 1 }
|
||||
],
|
||||
extraBody: { provider_option: "enabled" },
|
||||
extraHeaders: { "x-provider-option": "enabled" },
|
||||
capabilities: [
|
||||
{ baseUrl: `${baseUrlValue}/v1`, source: "detected", type: "openai_chat_completions" },
|
||||
{ baseUrl: `${baseUrlValue}/v1`, source: "detected", type: "openai_image_generations" },
|
||||
{ baseUrl: `${baseUrlValue}/v1`, source: "detected", type: "openai_video_generations" }
|
||||
],
|
||||
models: ["grok-imagine-image-quality", "grok-imagine-video"],
|
||||
name: "Media Provider"
|
||||
}];
|
||||
config.virtualModelProfiles = [{
|
||||
baseModel: { fixedModel: "Media Provider/grok-imagine-image-quality", mode: "fixed" },
|
||||
displayName: "Media Test",
|
||||
enabled: true,
|
||||
execution: { clientToolsPolicy: "allow", maxToolCalls: 5, maxTurns: 6, mode: "tool_loop", streamMode: "optimistic" },
|
||||
id: "test",
|
||||
key: "test",
|
||||
match: { exactAliases: ["media-test"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true },
|
||||
metadata: {
|
||||
fusionMedia: {
|
||||
imageEditToolName: "image_edit_test",
|
||||
imageGenerateToolName: "image_generate_test",
|
||||
imageModelSelector: "Media Provider/grok-imagine-image-quality",
|
||||
jobCancelToolName: "media_job_cancel_test",
|
||||
jobGetToolName: "media_job_get_test",
|
||||
videoModelSelector: "Media Provider/grok-imagine-video",
|
||||
videoStartToolName: "video_generate_test"
|
||||
}
|
||||
},
|
||||
tools: ["image_generate_test", "image_edit_test", "video_generate_test", "media_job_get_test", "media_job_cancel_test"].map((name) => ({ name, visibility: "client" }))
|
||||
}];
|
||||
return config;
|
||||
}
|
||||
|
||||
async function waitForJob(service, id) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const job = service.getJob(id);
|
||||
if (job.status === "succeeded" || job.status === "failed" || job.status === "canceled") return job;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("Timed out waiting for media job");
|
||||
}
|
||||
|
||||
function baseUrl(server) {
|
||||
const address = server.address();
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function listenOrSkip(t, server) {
|
||||
try {
|
||||
await listen(server);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === "EPERM" || error?.code === "EACCES") {
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function consume(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.once("error", reject);
|
||||
request.once("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
function json(response, body) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { grokCandidate, importGrokProvider } from "@ccr/core/agents/local-providers/grok.ts";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { writeCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-writer.ts";
|
||||
import { isCoreGatewayHealthy, spawnGatewayProcess } from "@ccr/core/gateway/core-runtime/supervisor.ts";
|
||||
import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared.ts";
|
||||
import { MediaService } from "@ccr/core/media/service.ts";
|
||||
import {
|
||||
MEDIA_ARTIFACT_PATH_PREFIX,
|
||||
handleMediaArtifactRequest,
|
||||
handleMediaToolsMcpRequest
|
||||
} from "@ccr/core/mcp/grok-media-mcp.ts";
|
||||
import { getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch.ts";
|
||||
|
||||
const localGatewayEntry = process.env.CCR_LIVE_AI_GATEWAY_ENTRY;
|
||||
const liveEnabled = process.env.CCR_LIVE_GROK_MEDIA === "1";
|
||||
|
||||
test("Fusion generates image and video through the local ai-gateway", { skip: !liveEnabled }, async () => {
|
||||
assert.ok(localGatewayEntry, "CCR_LIVE_AI_GATEWAY_ENTRY is required");
|
||||
assert.ok(existsSync(localGatewayEntry), `Local ai-gateway entry does not exist: ${localGatewayEntry}`);
|
||||
|
||||
const candidate = grokCandidate();
|
||||
assert.equal(candidate.importable, true, candidate.detail);
|
||||
const imported = await importGrokProvider(candidate, []);
|
||||
const providerName = imported.provider.name;
|
||||
const providerId = "grok-cli-api";
|
||||
const providerProtocol = imported.provider.protocol;
|
||||
const imageModel = "grok-imagine-image-quality";
|
||||
const videoModel = "grok-imagine-video";
|
||||
assert.ok(imported.provider.models.includes(imageModel));
|
||||
assert.ok(imported.provider.models.includes(videoModel));
|
||||
|
||||
const configRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-fusion-live-config-"));
|
||||
const artifactRoot = path.join(os.tmpdir(), `ccr-fusion-live-artifacts-${Date.now()}`);
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: path.join(configRoot, "gateway.config.json") });
|
||||
const corePort = await availablePort();
|
||||
const coreEndpoint = `http://127.0.0.1:${corePort}`;
|
||||
const coreAuthToken = randomBytes(32).toString("base64url");
|
||||
const upstreamProxyUrl = await getSystemProxyUrlForProtocol("https", config);
|
||||
const toolNames = {
|
||||
imageEdit: "image_edit_grok_live",
|
||||
imageGenerate: "image_generate_grok_live",
|
||||
jobCancel: "media_job_cancel_grok_live",
|
||||
jobGet: "media_job_get_grok_live",
|
||||
videoGenerate: "video_generate_grok_live"
|
||||
};
|
||||
|
||||
config.gateway.coreHost = "127.0.0.1";
|
||||
config.gateway.corePort = corePort;
|
||||
config.gateway.enabled = true;
|
||||
config.mediaTools = {
|
||||
...config.mediaTools,
|
||||
artifactTtlHours: 24,
|
||||
enabled: true,
|
||||
jobTimeoutMs: 12 * 60 * 1000
|
||||
};
|
||||
config.Providers = [{
|
||||
...imported.provider,
|
||||
id: providerId,
|
||||
type: providerProtocol
|
||||
}];
|
||||
config.providerPlugins = materializeProviderPlugins(
|
||||
imported.providerPlugins,
|
||||
providerName,
|
||||
providerId,
|
||||
providerProtocol
|
||||
);
|
||||
config.virtualModelProfiles = [{
|
||||
baseModel: { fixedModel: `${providerName}/grok-4.5`, mode: "fixed" },
|
||||
displayName: "Grok Live Media",
|
||||
enabled: true,
|
||||
id: "grok-live-media",
|
||||
key: "grok-live-media",
|
||||
match: { exactAliases: ["grok-live-media"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true },
|
||||
metadata: {
|
||||
fusionMedia: {
|
||||
imageEditToolName: toolNames.imageEdit,
|
||||
imageGenerateToolName: toolNames.imageGenerate,
|
||||
imageModelSelector: `${providerName}/${imageModel}`,
|
||||
jobCancelToolName: toolNames.jobCancel,
|
||||
jobGetToolName: toolNames.jobGet,
|
||||
videoModelSelector: `${providerName}/${videoModel}`,
|
||||
videoStartToolName: toolNames.videoGenerate
|
||||
}
|
||||
},
|
||||
tools: Object.values(toolNames).map((name) => ({ name, visibility: "client" }))
|
||||
}];
|
||||
|
||||
const service = new MediaService(artifactRoot);
|
||||
let child;
|
||||
let mcpServer;
|
||||
let completed = false;
|
||||
const gatewayOutput = [];
|
||||
try {
|
||||
mcpServer = createServer(async (request, response) => {
|
||||
const requestUrl = new URL(request.url ?? "/", mcpEndpoint(mcpServer));
|
||||
if (requestUrl.pathname === "/mcp") {
|
||||
await handleMediaToolsMcpRequest(request, response, service);
|
||||
return;
|
||||
}
|
||||
if (requestUrl.pathname.startsWith(MEDIA_ARTIFACT_PATH_PREFIX)) {
|
||||
handleMediaArtifactRequest(request, response, requestUrl, service);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
await listen(mcpServer);
|
||||
config.gateway.host = "127.0.0.1";
|
||||
config.gateway.port = Number(new URL(mcpEndpoint(mcpServer)).port);
|
||||
|
||||
service.start(config, mcpEndpoint(mcpServer), {
|
||||
authHeader: coreGatewayAuthHeader,
|
||||
authToken: coreAuthToken,
|
||||
baseUrl: coreEndpoint
|
||||
});
|
||||
await writeCoreGatewayConfig(
|
||||
config,
|
||||
randomBytes(24).toString("base64url"),
|
||||
randomBytes(24).toString("base64url"),
|
||||
coreAuthToken,
|
||||
undefined,
|
||||
upstreamProxyUrl
|
||||
);
|
||||
enableGatewayDiagnostics(config.gateway.generatedConfigFile);
|
||||
|
||||
const previousEntry = process.env.CCR_GATEWAY_ENTRY;
|
||||
process.env.CCR_GATEWAY_ENTRY = localGatewayEntry;
|
||||
try {
|
||||
child = spawnGatewayProcess(config, upstreamProxyUrl, randomUUID(), coreAuthToken);
|
||||
} finally {
|
||||
restoreEnv("CCR_GATEWAY_ENTRY", previousEntry);
|
||||
}
|
||||
capture(child.stdout, gatewayOutput);
|
||||
capture(child.stderr, gatewayOutput);
|
||||
child.on("error", (error) => gatewayOutput.push(`child process error: ${error.message}\n`));
|
||||
await waitForGateway(coreEndpoint, child, gatewayOutput);
|
||||
console.log(`LIVE_PHASE gateway_ready endpoint=${coreEndpoint} proxy=${upstreamProxyUrl ? "configured" : "direct"}`);
|
||||
|
||||
const listed = await mcpRequest(mcpServer, "tools/list");
|
||||
assert.deepEqual(
|
||||
listed.tools.map((tool) => tool.name),
|
||||
[toolNames.imageGenerate, toolNames.imageEdit, toolNames.videoGenerate, toolNames.jobGet, toolNames.jobCancel]
|
||||
);
|
||||
console.log(`LIVE_PHASE fusion_tools_discovered count=${listed.tools.length}`);
|
||||
|
||||
const imageJob = parseToolResult(await mcpRequest(mcpServer, "tools/call", {
|
||||
arguments: {
|
||||
aspect_ratio: "1:1",
|
||||
idempotency_key: `ccr-live-image-${randomUUID()}`,
|
||||
prompt: "A clean integration-test illustration: one glossy teal sphere floating over a soft white background, subtle studio shadow, no text, square composition."
|
||||
},
|
||||
name: toolNames.imageGenerate
|
||||
}));
|
||||
assertSucceededArtifact(imageJob, "image/");
|
||||
await assertArtifactUrl(imageJob.artifact.url, imageJob.artifact.sizeBytes);
|
||||
console.log(`LIVE_PHASE image_succeeded id=${imageJob.id} bytes=${imageJob.artifact.sizeBytes} mime=${imageJob.artifact.mimeType}`);
|
||||
|
||||
let videoJob = parseToolResult(await mcpRequest(mcpServer, "tools/call", {
|
||||
arguments: {
|
||||
duration: 6,
|
||||
idempotency_key: `ccr-live-video-${randomUUID()}`,
|
||||
prompt: "A glossy teal sphere slowly rotates while floating over a soft white studio background, fixed camera, gentle shadow movement, no text.",
|
||||
resolution: "480p"
|
||||
},
|
||||
name: toolNames.videoGenerate
|
||||
}));
|
||||
assert.ok(["queued", "running"].includes(videoJob.status), JSON.stringify(videoJob));
|
||||
console.log(`LIVE_PHASE video_submitted id=${videoJob.id} status=${videoJob.status}`);
|
||||
videoJob = await waitForVideo(mcpServer, toolNames.jobGet, videoJob.id);
|
||||
assertSucceededArtifact(videoJob, "video/");
|
||||
await assertArtifactUrl(videoJob.artifact.url, videoJob.artifact.sizeBytes);
|
||||
console.log(`LIVE_PHASE video_succeeded id=${videoJob.id} bytes=${videoJob.artifact.sizeBytes} mime=${videoJob.artifact.mimeType}`);
|
||||
|
||||
console.log(`CCR_FUSION_LIVE_RESULT=${JSON.stringify({
|
||||
aiGatewayEntry: localGatewayEntry,
|
||||
artifactRoot,
|
||||
image: publicArtifactSummary(imageJob),
|
||||
provider: providerName,
|
||||
video: publicArtifactSummary(videoJob)
|
||||
})}`);
|
||||
completed = true;
|
||||
} catch (error) {
|
||||
const diagnostics = sanitizeGatewayOutput(gatewayOutput.join(""));
|
||||
if (diagnostics) console.error(`LOCAL_AI_GATEWAY_DIAGNOSTICS\n${diagnostics}`);
|
||||
if (process.env.CCR_LIVE_KEEP_CONFIG === "1") console.error(`LIVE_CONFIG_ROOT=${configRoot}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await service.stop();
|
||||
if (mcpServer) await close(mcpServer);
|
||||
if (child && child.exitCode === null && !child.killed) child.kill();
|
||||
if (child && child.exitCode === null) await waitForExit(child, 5000);
|
||||
if (completed || process.env.CCR_LIVE_KEEP_CONFIG !== "1") {
|
||||
rmSync(configRoot, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function materializeProviderPlugins(templates, providerName, providerId, protocol) {
|
||||
const slug = providerName.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "provider";
|
||||
const replacements = {
|
||||
__CCR_PROVIDER_INTERNAL_NAME__: `${providerId}::${protocol}`,
|
||||
__CCR_PROVIDER_NAME__: providerName,
|
||||
__CCR_PROVIDER_NAME_SLUG__: slug
|
||||
};
|
||||
return templates.map((template) => replacePlaceholders(template, replacements));
|
||||
}
|
||||
|
||||
function enableGatewayDiagnostics(file) {
|
||||
const generated = JSON.parse(readFileSync(file, "utf8"));
|
||||
generated.logging = { accessLog: false, enabled: true, level: "info" };
|
||||
const runtimeRoot = path.join(process.cwd(), ".test-dist", "core", "runtime");
|
||||
for (const plugin of generated.plugins ?? []) {
|
||||
if (plugin.key === "ccr-upstream-header-sanitizer") {
|
||||
plugin.modulePath = path.join(runtimeRoot, "upstream-header-sanitizer.js");
|
||||
}
|
||||
}
|
||||
for (const server of generated.agent?.mcpServers ?? []) {
|
||||
if (server.name === "ccr-media-tools") {
|
||||
server.args = [path.join(runtimeRoot, "media-tools-proxy-mcp.js")];
|
||||
}
|
||||
}
|
||||
writeFileSync(file, `${JSON.stringify(generated, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function replacePlaceholders(value, replacements) {
|
||||
if (typeof value === "string") {
|
||||
return Object.entries(replacements).reduce((result, [search, replacement]) => result.split(search).join(replacement), value);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((item) => replacePlaceholders(item, replacements));
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replacePlaceholders(item, replacements)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
const server = createServer();
|
||||
await listen(server);
|
||||
const port = server.address().port;
|
||||
await close(server);
|
||||
return port;
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
function mcpEndpoint(server) {
|
||||
const address = server.address();
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function mcpRequest(server, method, params) {
|
||||
const response = await fetch(`${mcpEndpoint(server)}/mcp`, {
|
||||
body: JSON.stringify({ id: randomUUID(), jsonrpc: "2.0", method, ...(params ? { params } : {}) }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST"
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(payload));
|
||||
if (payload.error) throw new Error(`MCP ${method} failed: ${payload.error.message}`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function parseToolResult(result) {
|
||||
assert.ok(Array.isArray(result.content));
|
||||
return JSON.parse(result.content[0].text);
|
||||
}
|
||||
|
||||
function assertSucceededArtifact(job, mimePrefix) {
|
||||
assert.equal(job.status, "succeeded", JSON.stringify(job));
|
||||
assert.ok(job.artifact, JSON.stringify(job));
|
||||
assert.ok(job.artifact.mimeType.startsWith(mimePrefix), JSON.stringify(job.artifact));
|
||||
assert.ok(job.artifact.sizeBytes > 0);
|
||||
assert.ok(existsSync(job.artifact.localPath), job.artifact.localPath);
|
||||
assert.equal(statSync(job.artifact.localPath).size, job.artifact.sizeBytes);
|
||||
}
|
||||
|
||||
async function assertArtifactUrl(url, expectedBytes) {
|
||||
const response = await fetch(url, { headers: { range: "bytes=0-31" } });
|
||||
assert.equal(response.status, 206);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
assert.equal(bytes.byteLength, Math.min(32, expectedBytes));
|
||||
}
|
||||
|
||||
async function waitForVideo(server, jobGetToolName, jobId) {
|
||||
const deadline = Date.now() + 13 * 60 * 1000;
|
||||
let lastStatus;
|
||||
let lastProgressAt = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const job = parseToolResult(await mcpRequest(server, "tools/call", {
|
||||
arguments: { job_id: jobId },
|
||||
name: jobGetToolName
|
||||
}));
|
||||
if (job.status !== lastStatus || Date.now() - lastProgressAt >= 30000) {
|
||||
console.log(`LIVE_PHASE video_poll id=${jobId} status=${job.status}`);
|
||||
lastStatus = job.status;
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
if (["succeeded", "failed", "canceled"].includes(job.status)) return job;
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
throw new Error(`Timed out waiting for video job ${jobId}`);
|
||||
}
|
||||
|
||||
async function waitForGateway(endpoint, child, gatewayOutput) {
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
throw new Error(`Local ai-gateway exited with code ${child.exitCode}: ${sanitizeGatewayOutput(gatewayOutput.join(""))}`);
|
||||
}
|
||||
if (await isCoreGatewayHealthy(endpoint)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
throw new Error(`Local ai-gateway did not become healthy: ${sanitizeGatewayOutput(gatewayOutput.join(""))}`);
|
||||
}
|
||||
|
||||
function capture(stream, output) {
|
||||
stream?.on("data", (chunk) => {
|
||||
output.push(chunk.toString());
|
||||
if (output.length > 400) output.splice(0, output.length - 400);
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeGatewayOutput(value) {
|
||||
return value
|
||||
.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")
|
||||
.replace(/[A-Za-z0-9_-]{40,}/g, "[redacted]")
|
||||
.trim()
|
||||
.slice(-12000);
|
||||
}
|
||||
|
||||
function publicArtifactSummary(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
localPath: job.artifact.localPath,
|
||||
mimeType: job.artifact.mimeType,
|
||||
sha256: job.artifact.sha256,
|
||||
sizeBytes: job.artifact.sizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(name, value) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, timeoutMs);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
test("media tools stdio proxy exposes its compiled catalog and forwards calls with CCR auth", async (t) => {
|
||||
const seen = { authorization: "", payload: undefined };
|
||||
const server = createServer(async (request, response) => {
|
||||
seen.authorization = request.headers.authorization ?? "";
|
||||
seen.payload = JSON.parse(await consume(request));
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
id: seen.payload.id,
|
||||
jsonrpc: "2.0",
|
||||
result: { content: [{ text: "generated", type: "text" }] }
|
||||
}));
|
||||
});
|
||||
try {
|
||||
await listen(server);
|
||||
} catch (error) {
|
||||
if (error?.code === "EPERM" || error?.code === "EACCES") {
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
t.after(() => server.close());
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const runtime = path.join(process.cwd(), ".test-dist", "core", "runtime", "media-tools-proxy-mcp.js");
|
||||
const child = spawn(process.execPath, [runtime], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_MEDIA_MCP_API_KEY: "ccr-profile-test",
|
||||
CCR_MEDIA_MCP_REQUEST_TIMEOUT_MS: "5000",
|
||||
CCR_MEDIA_MCP_TOOLS_JSON: JSON.stringify([{
|
||||
description: "Generate an image.",
|
||||
inputSchema: { properties: { prompt: { type: "string" } }, required: ["prompt"], type: "object" },
|
||||
name: "image_generate_glm_5_2v"
|
||||
}]),
|
||||
CCR_MEDIA_MCP_URL: `http://127.0.0.1:${address.port}/mcp`,
|
||||
ELECTRON_RUN_AS_NODE: "1"
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
t.after(() => {
|
||||
if (!child.killed) child.kill();
|
||||
});
|
||||
|
||||
const listed = await sendJsonRpc(child, { id: 1, jsonrpc: "2.0", method: "tools/list", params: {} });
|
||||
assert.deepEqual(listed.result.tools.map((tool) => tool.name), ["image_generate_glm_5_2v"]);
|
||||
|
||||
const called = await sendJsonRpc(child, {
|
||||
id: 2,
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
arguments: { prompt: "A blue cup" },
|
||||
name: "image_generate_glm_5_2v"
|
||||
}
|
||||
});
|
||||
assert.equal(called.result.content[0].text, "generated");
|
||||
assert.equal(seen.authorization, "Bearer ccr-profile-test");
|
||||
assert.equal(seen.payload.method, "tools/call");
|
||||
assert.equal(seen.payload.params.name, "image_generate_glm_5_2v");
|
||||
});
|
||||
|
||||
function sendJsonRpc(child, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = Buffer.alloc(0);
|
||||
let stderr = "";
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout.off("data", onStdout);
|
||||
child.stderr.off("data", onStderr);
|
||||
child.off("exit", onExit);
|
||||
child.off("error", onError);
|
||||
};
|
||||
const finish = (value) => {
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const fail = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onStdout = (chunk) => {
|
||||
stdout = Buffer.concat([stdout, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
const parsed = readJsonRpcFrame(stdout);
|
||||
if (parsed) finish(parsed);
|
||||
};
|
||||
const onStderr = (chunk) => {
|
||||
stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
};
|
||||
const onExit = (code, signal) => fail(new Error(`media tools proxy exited before response: code=${code ?? ""} signal=${signal ?? ""} stderr=${stderr}`));
|
||||
const onError = (error) => fail(error);
|
||||
const timer = setTimeout(() => fail(new Error(`Timed out waiting for media tools proxy response. stderr=${stderr}`)), 5000);
|
||||
|
||||
child.stdout.on("data", onStdout);
|
||||
child.stderr.on("data", onStderr);
|
||||
child.once("exit", onExit);
|
||||
child.once("error", onError);
|
||||
|
||||
const message = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
child.stdin.write(`Content-Length: ${message.byteLength}\r\n\r\n`);
|
||||
child.stdin.write(message);
|
||||
});
|
||||
}
|
||||
|
||||
function readJsonRpcFrame(buffer) {
|
||||
const headerEnd = buffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) return undefined;
|
||||
const header = buffer.subarray(0, headerEnd).toString("utf8");
|
||||
const match = header.match(/content-length:\s*(\d+)/i);
|
||||
if (!match) throw new Error(`Missing Content-Length in MCP response: ${header}`);
|
||||
const length = Number(match[1]);
|
||||
const bodyStart = headerEnd + 4;
|
||||
const bodyEnd = bodyStart + length;
|
||||
if (buffer.length < bodyEnd) return undefined;
|
||||
return JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString("utf8"));
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function consume(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.once("error", reject);
|
||||
request.once("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { codexElectronArgsForTest } from "@ccr/core/agents/codex/app-launch.ts";
|
||||
import {
|
||||
codexMediaPreviewBridgeForTest,
|
||||
prepareCodexAppCdpUserDataDir,
|
||||
shouldEnableCodexMediaPreviewBridge
|
||||
} from "@ccr/core/agents/codex/media-preview-bridge.ts";
|
||||
|
||||
const token = "A".repeat(32);
|
||||
const imageId = "123e4567-e89b-42d3-a456-426614174000";
|
||||
const videoId = "223e4567-e89b-42d3-a456-426614174001";
|
||||
const mismatchId = "323e4567-e89b-42d3-a456-426614174002";
|
||||
const redirectId = "423e4567-e89b-42d3-a456-426614174003";
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
const mp4 = Buffer.concat([
|
||||
Buffer.from([0, 0, 0, 24]),
|
||||
Buffer.from("ftypisom", "ascii"),
|
||||
Buffer.alloc(12)
|
||||
]);
|
||||
|
||||
test("Codex inline media bridge is enabled only for configured Fusion media and honors the kill switch", () => {
|
||||
const previous = process.env.CCR_CODEX_INLINE_VIDEO_PREVIEW;
|
||||
try {
|
||||
delete process.env.CCR_CODEX_INLINE_VIDEO_PREVIEW;
|
||||
assert.equal(shouldEnableCodexMediaPreviewBridge(true), true);
|
||||
assert.equal(shouldEnableCodexMediaPreviewBridge(false), false);
|
||||
process.env.CCR_CODEX_INLINE_VIDEO_PREVIEW = "off";
|
||||
assert.equal(shouldEnableCodexMediaPreviewBridge(true), false);
|
||||
process.env.CCR_CODEX_INLINE_VIDEO_PREVIEW = "1";
|
||||
assert.equal(shouldEnableCodexMediaPreviewBridge(true), true);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CCR_CODEX_INLINE_VIDEO_PREVIEW;
|
||||
else process.env.CCR_CODEX_INLINE_VIDEO_PREVIEW = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex App launch uses a random loopback DevTools port and removes stale discovery state", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-cdp-"));
|
||||
try {
|
||||
const activePort = path.join(root, "DevToolsActivePort");
|
||||
writeFileSync(activePort, "49152\n/devtools/browser/stale\n");
|
||||
prepareCodexAppCdpUserDataDir(root);
|
||||
assert.throws(() => readFileSync(activePort), { code: "ENOENT" });
|
||||
|
||||
const args = codexElectronArgsForTest(root);
|
||||
assert.ok(args.includes("--remote-debugging-port=0"));
|
||||
assert.ok(args.includes("--remote-debugging-address=127.0.0.1"));
|
||||
assert.ok(args.includes(`--user-data-dir=${root}`));
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex media artifact URLs are restricted to the configured CCR origin, current path, UUID, and token", () => {
|
||||
const endpoint = "http://127.0.0.1:3457";
|
||||
const valid = `${endpoint}/__ccr/media/artifacts/${imageId}?token=${token}`;
|
||||
assert.deepEqual(codexMediaPreviewBridgeForTest.validateUrl(valid, endpoint), {
|
||||
artifactId: imageId,
|
||||
url: valid
|
||||
});
|
||||
assert.throws(() => codexMediaPreviewBridgeForTest.validateUrl(valid.replace("127.0.0.1", "localhost"), endpoint), /origin/);
|
||||
assert.throws(() => codexMediaPreviewBridgeForTest.validateUrl(valid.replace("/__ccr/media/", "/__ccr/grok-media/"), endpoint), /artifact path/);
|
||||
assert.throws(() => codexMediaPreviewBridgeForTest.validateUrl(valid.replace(imageId, "not-an-id"), endpoint), /identifier/);
|
||||
assert.throws(() => codexMediaPreviewBridgeForTest.validateUrl(`${valid}&extra=1`, endpoint), /access token/);
|
||||
assert.throws(() => codexMediaPreviewBridgeForTest.validateUrl(valid.replace(token, "short"), endpoint), /access token/);
|
||||
});
|
||||
|
||||
test("Codex page bootstrap uses Blob media, semantic response hooks, readiness gating, and no CSP bypass", () => {
|
||||
const script = codexMediaPreviewBridgeForTest.injectionScript("http://127.0.0.1:3457");
|
||||
assert.doesNotThrow(() => new Function(script));
|
||||
assert.match(script, /__ccrMediaPreviewRequest/);
|
||||
assert.match(script, /MutationObserver/);
|
||||
assert.match(script, /data-response-annotation-conversation/);
|
||||
assert.match(script, /Open Web preview/);
|
||||
assert.match(script, /createObjectURL/);
|
||||
assert.match(script, /canplay/);
|
||||
assert.doesNotMatch(script, /innerHTML/);
|
||||
assert.doesNotMatch(script, /setBypassCSP/);
|
||||
assert.doesNotMatch(script, /autoplay\s*=/);
|
||||
});
|
||||
|
||||
test("Codex media loader accepts signed image and video bytes and rejects redirects and MIME mismatches", async (t) => {
|
||||
const server = createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url, "http://127.0.0.1");
|
||||
const id = requestUrl.pathname.split("/").at(-1);
|
||||
if (requestUrl.searchParams.get("token") !== token) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (id === imageId) {
|
||||
response.writeHead(200, { "content-length": png.byteLength, "content-type": "image/png" });
|
||||
response.end(png);
|
||||
return;
|
||||
}
|
||||
if (id === videoId) {
|
||||
response.writeHead(200, { "content-length": mp4.byteLength, "content-type": "video/mp4" });
|
||||
response.end(mp4);
|
||||
return;
|
||||
}
|
||||
if (id === mismatchId) {
|
||||
response.writeHead(200, { "content-length": png.byteLength, "content-type": "video/mp4" });
|
||||
response.end(png);
|
||||
return;
|
||||
}
|
||||
if (id === redirectId) {
|
||||
response.writeHead(302, { location: `/__ccr/media/artifacts/${imageId}?token=${token}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => server.close());
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const endpoint = `http://127.0.0.1:${address.port}`;
|
||||
const artifactUrl = (id) => `${endpoint}/__ccr/media/artifacts/${id}?token=${token}`;
|
||||
|
||||
const image = await codexMediaPreviewBridgeForTest.loadArtifact(artifactUrl(imageId), endpoint);
|
||||
assert.equal(image.mimeType, "image/png");
|
||||
assert.deepEqual(image.bytes, png);
|
||||
const video = await codexMediaPreviewBridgeForTest.loadArtifact(artifactUrl(videoId), endpoint);
|
||||
assert.equal(video.mimeType, "video/mp4");
|
||||
assert.deepEqual(video.bytes, mp4);
|
||||
await assert.rejects(codexMediaPreviewBridgeForTest.loadArtifact(artifactUrl(mismatchId), endpoint), /did not match/);
|
||||
await assert.rejects(codexMediaPreviewBridgeForTest.loadArtifact(artifactUrl(redirectId), endpoint), /request failed/);
|
||||
});
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
grokDefaultSubscriptionEndpoint,
|
||||
grokModelCatalogFromPayloadForTest,
|
||||
importGrokProvider,
|
||||
normalizeGrokProviderAccountConfig
|
||||
normalizeGrokProviderAccountConfig,
|
||||
normalizeGrokProviderMediaCapabilities
|
||||
} from "@ccr/core/agents/local-providers/grok.ts";
|
||||
import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared.ts";
|
||||
|
||||
@@ -39,6 +40,12 @@ test("Grok local provider imports bearer token and model override plugin", async
|
||||
assert.equal(result.provider.baseUrl, grokDefaultBaseUrl);
|
||||
assert.equal(result.provider.protocol, "openai_responses");
|
||||
assert.equal(result.provider.apiKey, "ccr-local-agent-login");
|
||||
assert.deepEqual(
|
||||
result.provider.capabilities.map((capability) => capability.type),
|
||||
["openai_responses", "openai_image_generations", "openai_video_generations"]
|
||||
);
|
||||
assert.ok(result.provider.models.includes("grok-imagine-image-quality"));
|
||||
assert.ok(result.provider.models.includes("grok-imagine-video"));
|
||||
assert.equal(result.provider.account?.enabled, true);
|
||||
assert.equal(result.provider.account?.connectors?.length, 2);
|
||||
assert.equal(result.provider.account?.connectors?.[0]?.type, "http-json");
|
||||
@@ -250,6 +257,24 @@ test("Grok local provider account config keeps custom connectors", () => {
|
||||
assert.equal(provider.account, account);
|
||||
});
|
||||
|
||||
test("persisted Grok Agent providers are upgraded with gateway media capabilities", () => {
|
||||
const provider = normalizeGrokProviderMediaCapabilities({
|
||||
api_base_url: grokDefaultBaseUrl,
|
||||
api_key: localAgentProviderApiKey,
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
protocol: "openai_responses"
|
||||
});
|
||||
|
||||
assert.deepEqual(provider.capabilities?.map((capability) => capability.type), [
|
||||
"openai_responses",
|
||||
"openai_image_generations",
|
||||
"openai_video_generations"
|
||||
]);
|
||||
assert.ok(provider.models.includes("grok-imagine-image-quality"));
|
||||
assert.ok(provider.models.includes("grok-imagine-video"));
|
||||
});
|
||||
|
||||
async function withGrokHome(run) {
|
||||
const previousGrokHome = process.env.GROK_HOME;
|
||||
const previousGrokAuthFile = process.env.GROK_AUTH_FILE;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts";
|
||||
import { shouldRunGatewayRuntime } from "@ccr/core/gateway/core-runtime/supervisor.ts";
|
||||
|
||||
test("media tools start their internal gateway runtime when the public gateway is disabled", () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.gateway.enabled = false;
|
||||
config.proxy.enabled = false;
|
||||
config.mediaTools.enabled = true;
|
||||
|
||||
assert.equal(shouldRunGatewayRuntime(config), true);
|
||||
});
|
||||
|
||||
test("core gateway compiles media capabilities and provider plugin aliases for credentials", async () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.Providers = [{
|
||||
api_base_url: "https://chat.example/v1",
|
||||
capabilities: [
|
||||
{ baseUrl: "https://chat.example/v1", source: "detected", type: "openai_chat_completions" },
|
||||
{ baseUrl: "https://media.example/v1", source: "detected", type: "openai_image_generations" },
|
||||
{ baseUrl: "https://media.example/v1", source: "detected", type: "openai_video_generations" }
|
||||
],
|
||||
credentials: [{ apiKey: "provider-key", enabled: true, id: "primary", priority: 1 }],
|
||||
id: "media-provider",
|
||||
models: ["image-model", "video-model"],
|
||||
name: "Media Provider",
|
||||
type: "openai_chat_completions"
|
||||
}];
|
||||
config.providerPlugins = [{
|
||||
auth: { headers: { authorization: "Bearer provider-token" } },
|
||||
enabled: true,
|
||||
key: "media-provider-auth",
|
||||
provider: "openai",
|
||||
providerName: "Media Provider"
|
||||
}];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-token",
|
||||
"core-token"
|
||||
);
|
||||
const providers = compiled.providers;
|
||||
const providerPlugins = compiled.providerPlugins;
|
||||
assert.ok(Array.isArray(providers));
|
||||
assert.ok(Array.isArray(providerPlugins));
|
||||
|
||||
assert.deepEqual(providers.map((provider) => [provider.name, provider.type, provider.baseurl]), [
|
||||
["media-provider::openai_chat_completions::cred:primary", "openai_chat_completions", "https://chat.example/v1"],
|
||||
["media-provider::openai_image_generations::cred:primary", "openai_image_generations", "https://media.example/v1"],
|
||||
["media-provider::openai_video_generations::cred:primary", "openai_video_generations", "https://media.example/v1"]
|
||||
]);
|
||||
assert.deepEqual(
|
||||
providerPlugins.map((plugin) => plugin.providerName).filter(Boolean).sort(),
|
||||
[
|
||||
"Media Provider",
|
||||
"media-provider::openai_chat_completions::cred:primary",
|
||||
"media-provider::openai_image_generations::cred:primary",
|
||||
"media-provider::openai_video_generations::cred:primary"
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { mediaToolsConfigFromRawForTest, virtualModelProfileFromRawForTest } from "@ccr/core/config/config.ts";
|
||||
import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change.ts";
|
||||
|
||||
test("ToolHub config changes restart the gateway runtime", () => {
|
||||
@@ -25,6 +26,57 @@ test("ToolHub config changes restart the gateway runtime", () => {
|
||||
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true);
|
||||
});
|
||||
|
||||
test("media tool policy changes restart the gateway runtime", () => {
|
||||
const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
next.mediaTools.enabled = true;
|
||||
|
||||
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true);
|
||||
});
|
||||
|
||||
test("legacy Grok media input migrates only internal policy and drops xAI-specific execution fields", () => {
|
||||
const migrated = mediaToolsConfigFromRawForTest({
|
||||
allowedInputRoots: ["/tmp/media"],
|
||||
apiKey: "must-not-survive",
|
||||
artifactTtlHours: 48,
|
||||
backend: "xai-api",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
enabled: true,
|
||||
imageModel: "legacy-image-model",
|
||||
maxImageConcurrency: 3,
|
||||
videoModel: "legacy-video-model"
|
||||
});
|
||||
|
||||
assert.deepEqual(migrated, {
|
||||
allowedInputRoots: ["/tmp/media"],
|
||||
artifactTtlHours: 48,
|
||||
enabled: true,
|
||||
maxImageConcurrency: 3
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy virtual model tool loop limits are removed from application config", () => {
|
||||
const migrated = virtualModelProfileFromRawForTest({
|
||||
execution: {
|
||||
clientToolsPolicy: "allow",
|
||||
maxToolCalls: 8,
|
||||
maxTurns: 6,
|
||||
mode: "tool_loop",
|
||||
streamMode: "optimistic"
|
||||
},
|
||||
id: "fusion-media"
|
||||
});
|
||||
|
||||
assert.deepEqual(migrated, {
|
||||
execution: {
|
||||
clientToolsPolicy: "allow",
|
||||
mode: "tool_loop",
|
||||
streamMode: "optimistic"
|
||||
},
|
||||
id: "fusion-media"
|
||||
});
|
||||
});
|
||||
|
||||
test("upstream proxy config changes restart the gateway runtime", () => {
|
||||
const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
|
||||
@@ -1420,7 +1420,7 @@ test("gateway strips unsupported OpenAI upstream request parameters", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("built-in Claude Code route overrides explicit virtual gateway models", async () => {
|
||||
test("explicit virtual gateway models override the built-in Claude Code profile route", async () => {
|
||||
const plugin = createRouterPlugin({
|
||||
profileModel: "Provider/claude-sonnet",
|
||||
virtualModelProfiles: [
|
||||
@@ -1446,9 +1446,69 @@ test("built-in Claude Code route overrides explicit virtual gateway models", asy
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(result.body.model, "Provider/claude-sonnet");
|
||||
assert.equal(result.decision.model, "Provider/claude-sonnet");
|
||||
assert.equal(result.decision.reason, "builtin:claude-code");
|
||||
assert.equal(result.body.model, "Fusion/kimisearch");
|
||||
assert.equal(result.decision.model, "Fusion/kimisearch");
|
||||
assert.equal(result.decision.reason, "default");
|
||||
});
|
||||
|
||||
test("Claude Code encoded model selections override the built-in profile route", async () => {
|
||||
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
|
||||
const selectedModel = "Provider/claude-opus";
|
||||
const encodedModel = `anthropic/claude-ccr-h${Buffer.from(selectedModel, "utf8").toString("hex")}`;
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: encodedModel
|
||||
},
|
||||
headers: {
|
||||
"user-agent": "claude-code/1.0"
|
||||
},
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(result.body.model, selectedModel);
|
||||
assert.equal(result.decision.model, selectedModel);
|
||||
assert.equal(result.decision.reason, "default");
|
||||
});
|
||||
|
||||
test("non-model router rewrites preserve the explicit Claude Code model selection", async () => {
|
||||
const plugin = createRouterPlugin({
|
||||
profileModel: "Provider/claude-sonnet",
|
||||
routerRules: [
|
||||
{
|
||||
condition: {
|
||||
left: "request.url",
|
||||
operator: "contains",
|
||||
right: "/v1/messages"
|
||||
},
|
||||
enabled: true,
|
||||
id: "add-client-header",
|
||||
name: "Add client header",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-client-route", operation: "set", value: "claude-code" }
|
||||
],
|
||||
type: "condition"
|
||||
}
|
||||
]
|
||||
});
|
||||
const headers = {
|
||||
"user-agent": "claude-code/1.0"
|
||||
};
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "Provider/claude-opus"
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(headers["x-client-route"], "claude-code");
|
||||
assert.equal(result.body.model, "Provider/claude-opus");
|
||||
assert.equal(result.decision.model, "Provider/claude-opus");
|
||||
assert.equal(result.decision.reason, "rule:add-client-header");
|
||||
});
|
||||
|
||||
test("built-in Codex route stays inactive when profile model is unset", async () => {
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
bundledToolHubMcpEntryPathCandidates,
|
||||
toolHubMcpRuntimeConfig
|
||||
} from "@ccr/core/mcp/toolhub-config.ts";
|
||||
import { MEDIA_TOOLS_MCP_PATH, mediaToolsMcpServer } from "@ccr/core/mcp/grok-media-config.ts";
|
||||
import { GROK_MEDIA_FUSION_TOOL_NAMES, MEDIA_TOOLS_MCP_SERVER_NAME } from "@ccr/core/contracts/app.ts";
|
||||
import { fusionFallbackToolDefinitions, fusionToolNamesBackedByMcpServers } from "@ccr/core/mcp/fusion-config.ts";
|
||||
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts";
|
||||
|
||||
test("ToolHub runtime candidates include the clean Core test build", () => {
|
||||
assert.ok(bundledToolHubMcpEntryPathCandidates().includes(
|
||||
@@ -17,6 +21,111 @@ test("ToolHub runtime candidates include the clean Core test build", () => {
|
||||
));
|
||||
});
|
||||
|
||||
test("Media tools are a Fusion MCP backend independent from ToolHub", () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.gateway.host = "0.0.0.0";
|
||||
config.mediaTools.enabled = true;
|
||||
config.mediaTools.jobTimeoutMs = 600000;
|
||||
config.Providers = [{ apiKey: "ccr-local-agent-login", baseUrl: "https://cli-chat-proxy.grok.com/v1", models: ["grok-4.5"], name: "Grok Agent" }];
|
||||
config.virtualModelProfiles = [{
|
||||
enabled: true,
|
||||
metadata: {
|
||||
fusionMedia: {
|
||||
imageEditToolName: "image_edit_profile_one",
|
||||
imageGenerateToolName: "image_generate_profile_one",
|
||||
imageModelSelector: "grok-cli",
|
||||
jobCancelToolName: "media_job_cancel_profile_one",
|
||||
jobGetToolName: "media_job_get_profile_one",
|
||||
videoModelSelector: "grok-cli",
|
||||
videoStartToolName: "video_generate_profile_one"
|
||||
}
|
||||
},
|
||||
tools: []
|
||||
}];
|
||||
|
||||
const media = mediaToolsMcpServer(config, { apiKey: "ccr-profile-test" });
|
||||
assert.ok(media);
|
||||
assert.equal(media.transport, "stdio");
|
||||
assert.equal(media.command, process.execPath);
|
||||
assert.ok(media.args[0].endsWith("media-tools-proxy-mcp.js"));
|
||||
assert.equal(media.env.CCR_MEDIA_MCP_API_KEY, "ccr-profile-test");
|
||||
assert.equal(media.env.CCR_MEDIA_MCP_URL, `http://127.0.0.1:${config.gateway.port}${MEDIA_TOOLS_MCP_PATH}`);
|
||||
assert.deepEqual(JSON.parse(media.env.CCR_MEDIA_MCP_TOOLS_JSON).map((tool) => tool.name), [
|
||||
"image_generate_profile_one",
|
||||
"image_edit_profile_one",
|
||||
"video_generate_profile_one",
|
||||
"media_job_get_profile_one",
|
||||
"media_job_cancel_profile_one"
|
||||
]);
|
||||
assert.equal(media.requestTimeoutMs, 630000);
|
||||
assert.deepEqual(
|
||||
[...fusionToolNamesBackedByMcpServers([media])].filter((name) => name.startsWith("grok_media_")),
|
||||
[...GROK_MEDIA_FUSION_TOOL_NAMES]
|
||||
);
|
||||
assert.deepEqual(fusionFallbackToolDefinitions([{
|
||||
enabled: true,
|
||||
tools: [{ name: "image_generate_profile_one", visibility: "client" }]
|
||||
}], fusionToolNamesBackedByMcpServers([media])), []);
|
||||
});
|
||||
|
||||
test("ToolHub does not absorb Fusion media tools", () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.toolHub.enabled = true;
|
||||
config.mediaTools.enabled = true;
|
||||
|
||||
assert.equal(toolHubMcpRuntimeConfig(config), undefined);
|
||||
});
|
||||
|
||||
test("Core Gateway registers media tools directly for Fusion models", async () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.mediaTools.enabled = true;
|
||||
config.toolHub.enabled = true;
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(config, "raw-trace-token", "billing-token", "core-token");
|
||||
const servers = compiled.agent.mcpServers;
|
||||
const media = servers.find((server) => server.name === MEDIA_TOOLS_MCP_SERVER_NAME);
|
||||
assert.ok(media);
|
||||
assert.equal(media.transport, "stdio");
|
||||
assert.ok(media.args[0].endsWith("media-tools-proxy-mcp.js"));
|
||||
assert.equal(servers.some((server) => server.name === "ccr-toolhub"), false);
|
||||
});
|
||||
|
||||
test("Core Gateway compiles one profile for each configured Fusion media model", async () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.mediaTools.enabled = true;
|
||||
config.Providers = [
|
||||
{ models: ["base-model"], name: "Provider" },
|
||||
{ apiKey: "ccr-local-agent-login", baseUrl: "https://cli-chat-proxy.grok.com/v1", models: ["grok-4.5"], name: "Grok Agent" }
|
||||
];
|
||||
config.virtualModelProfiles = [{
|
||||
baseModel: { fixedModel: "Provider/base-model", mode: "fixed" },
|
||||
displayName: "Media Test",
|
||||
enabled: true,
|
||||
execution: { clientToolsPolicy: "allow", maxToolCalls: 5, maxTurns: 6, mode: "tool_loop", streamMode: "buffered" },
|
||||
id: "media-test",
|
||||
key: "media-test",
|
||||
match: { exactAliases: ["media-test"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true },
|
||||
metadata: {
|
||||
fusionMedia: {
|
||||
imageGenerateToolName: "image_generate_media_test",
|
||||
imageModelSelector: "grok-cli"
|
||||
}
|
||||
},
|
||||
tools: [{ name: "image_generate_media_test", visibility: "internal" }]
|
||||
}];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(config, "raw-trace-token", "billing-token", "core-token");
|
||||
const mediaProfiles = compiled.virtualModelProfiles.filter((profile) => profile.metadata?.fusionMedia);
|
||||
|
||||
assert.equal(mediaProfiles.length, 1);
|
||||
assert.equal(mediaProfiles[0].key, "media-test");
|
||||
assert.equal(mediaProfiles[0].tools[0].name, "image_generate_media_test");
|
||||
assert.equal(mediaProfiles[0].materialization.enabled, true);
|
||||
assert.equal(mediaProfiles[0].execution.maxToolCalls, Number.MAX_SAFE_INTEGER);
|
||||
assert.equal(mediaProfiles[0].execution.maxTurns, Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
test("ToolHub runtime includes the built-in browser automation backend", () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.toolHub = {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
CCR_CLI_COMPANION_RUNTIME_FILE_NAMES,
|
||||
syncCcrCliCompanionRuntimes
|
||||
} from "@ccr/core/profiles/launch-service.ts";
|
||||
|
||||
test("CCR CLI launcher copies every bundled companion runtime next to ccr-cli.js", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-cli-runtime-"));
|
||||
try {
|
||||
const sourceDir = path.join(root, "dist", "main");
|
||||
const binDir = path.join(root, "bin");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const runtimeSource = path.join(sourceDir, "cli.js");
|
||||
writeFileSync(runtimeSource, "cli runtime\n");
|
||||
for (const fileName of CCR_CLI_COMPANION_RUNTIME_FILE_NAMES) {
|
||||
writeFileSync(path.join(sourceDir, fileName), `runtime:${fileName}\n`);
|
||||
writeFileSync(path.join(binDir, fileName), "stale\n");
|
||||
}
|
||||
|
||||
const synced = syncCcrCliCompanionRuntimes(runtimeSource, binDir);
|
||||
|
||||
assert.deepEqual(synced.map((file) => path.basename(file)), [...CCR_CLI_COMPANION_RUNTIME_FILE_NAMES]);
|
||||
for (const fileName of CCR_CLI_COMPANION_RUNTIME_FILE_NAMES) {
|
||||
assert.equal(readFileSync(path.join(binDir, fileName), "utf8"), `runtime:${fileName}\n`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isProfileAppMainProcessCommandForTest } from "@ccr/core/profiles/launch-service.ts";
|
||||
|
||||
const userDataDir = "/Users/example/.claude-code-router/profiles/codex/codex/.claude-code-router/codex-app-user-data/codex";
|
||||
|
||||
test("profile app process detection ignores persistent Chromium helper processes", () => {
|
||||
const main = `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=0 --user-data-dir=${userDataDir}`;
|
||||
const renderer = `/Applications/ChatGPT.app/Contents/Frameworks/Codex (Renderer) --type=renderer --user-data-dir=${userDataDir}`;
|
||||
const crashpad = `/Applications/ChatGPT.app/Contents/Frameworks/Codex Framework.framework/Helpers/browser_crashpad_handler --monitor-self --database=${userDataDir}/Crashpad --monitor-self-annotation=ptype=crashpad-handler`;
|
||||
|
||||
assert.equal(isProfileAppMainProcessCommandForTest(main, userDataDir), true);
|
||||
assert.equal(isProfileAppMainProcessCommandForTest(renderer, userDataDir), false);
|
||||
assert.equal(isProfileAppMainProcessCommandForTest(crashpad, userDataDir), false);
|
||||
assert.equal(isProfileAppMainProcessCommandForTest("/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", userDataDir), false);
|
||||
});
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
import { detectedProviderFromHeaders, newApiKeyUsageAccountConfig, newApiUserSelfConnectorConfig } from "@ccr/core/providers/new-api.ts";
|
||||
import {
|
||||
checkGatewayProviderConnectivity,
|
||||
isProviderProtocolEndpointSupportedForProbe
|
||||
isProviderProtocolEndpointSupportedForProbe,
|
||||
probeGatewayProvider
|
||||
} from "@ccr/core/providers/probe.ts";
|
||||
|
||||
test("protocol support probe does not treat Gemini auth errors as every protocol", () => {
|
||||
@@ -81,6 +82,100 @@ test("protocol support probe still rejects HTTP 400 route misses", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("media protocol support recognizes validation responses and HTTP 401 endpoints", () => {
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(
|
||||
422,
|
||||
"prompt is required",
|
||||
"openai_image_generations",
|
||||
[]
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(
|
||||
401,
|
||||
"Unauthorized",
|
||||
"openai_video_generations",
|
||||
[]
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(
|
||||
401,
|
||||
"Unauthorized",
|
||||
"openai_video_generations",
|
||||
["openai_video_generations"]
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(
|
||||
401,
|
||||
"Unauthorized",
|
||||
"openai_image_generations",
|
||||
[]
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(
|
||||
401,
|
||||
"unknown route",
|
||||
"openai_image_generations",
|
||||
[]
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(
|
||||
403,
|
||||
"Forbidden",
|
||||
"openai_video_generations",
|
||||
[]
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("provider probe exposes image and video capabilities when their endpoints return HTTP 401", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const paths = [];
|
||||
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = new URL(String(input));
|
||||
paths.push(url.pathname);
|
||||
return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 401
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const probe = await probeGatewayProvider({
|
||||
baseUrl: "http://127.0.0.1:49124/v1",
|
||||
forceRefresh: true,
|
||||
mode: "protocols",
|
||||
protocols: ["openai_image_generations", "openai_video_generations"]
|
||||
});
|
||||
|
||||
assert.deepEqual(paths, ["/v1/images/generations", "/v1/videos/generations"]);
|
||||
assert.deepEqual(
|
||||
probe.protocols.map(({ protocol, status, supported }) => ({ protocol, status, supported })),
|
||||
[
|
||||
{ protocol: "openai_image_generations", status: 401, supported: true },
|
||||
{ protocol: "openai_video_generations", status: 401, supported: true }
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
probe.capabilities?.map(({ type }) => type),
|
||||
["openai_image_generations", "openai_video_generations"]
|
||||
);
|
||||
});
|
||||
|
||||
test("connectivity probe applies provider plugin auth for local agent imports", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let called = false;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
enforceSingleEnabledGlobalProfilePerAgent,
|
||||
ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig,
|
||||
fallbackGatewayStatus, fallbackInfo, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage,
|
||||
fallbackUpdateStatus, fallbackUsageStats, formatAppError, GatewayProviderConfig,
|
||||
fallbackUpdateStatus, fallbackUsageStats, formatAppError, GatewayProviderConfig, GatewayProviderProtocol,
|
||||
fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile,
|
||||
GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid,
|
||||
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
|
||||
@@ -26,13 +26,13 @@ import {
|
||||
persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft,
|
||||
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileDraftWithDetectedAppPath, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
||||
profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
|
||||
providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot,
|
||||
providerBaseUrl, providerCapabilitiesForProtocols, providerCapabilitiesForSave, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerProtocolOptions, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot,
|
||||
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
|
||||
ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, SettingsPageId,
|
||||
routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig,
|
||||
uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect,
|
||||
useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId,
|
||||
VirtualModelDraft, virtualModelProfileFromDraft
|
||||
VirtualModelDraft, virtualModelProfileFromDraft, virtualModelProfilesUseMediaTools
|
||||
} from "./shared/index";
|
||||
import { startVisiblePolling } from "./shared/polling";
|
||||
import {
|
||||
@@ -1389,7 +1389,7 @@ function App() {
|
||||
const candidates = providerProbeCandidates(providerDraft)
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
protocols: candidate.protocols.filter((protocol) => protocols.includes(protocol))
|
||||
protocols: candidate.protocols.filter((protocol) => protocols.some((selected) => selected === protocol))
|
||||
}))
|
||||
.filter((candidate) => isProviderProbeCandidateReady(candidate) && candidate.protocols.length > 0);
|
||||
|
||||
@@ -1498,11 +1498,19 @@ function App() {
|
||||
const modelDescriptions = modelDescriptionsForModels(providerDraft.modelDescriptions, models);
|
||||
const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models);
|
||||
const modelMetadata = modelMetadataForModels(providerDraft.modelMetadata, models);
|
||||
const capabilities = providerCapabilitiesForProtocols(providerDraft.baseUrl, protocolsToSave, probe, presetCapabilitiesFromDraft(providerDraft));
|
||||
const existingProvider = providerEditIndex !== undefined ? draftConfig.Providers[providerEditIndex] : undefined;
|
||||
const capabilities = providerCapabilitiesForSave(
|
||||
providerCapabilitiesForProtocols(providerDraft.baseUrl, protocolsToSave, probe, presetCapabilitiesFromDraft(providerDraft)),
|
||||
providerDraft.capabilities,
|
||||
existingProvider ? providerBaseUrl(existingProvider) : undefined,
|
||||
providerDraft.baseUrl
|
||||
);
|
||||
const primaryCapability =
|
||||
capabilities.find((capability) => capability.type === fallbackProtocol) ??
|
||||
capabilities[0];
|
||||
const protocol = primaryCapability?.type ?? fallbackProtocol;
|
||||
capabilities.find((capability) => providerProtocolOptions.some((option) => option.value === capability.type));
|
||||
const protocol = primaryCapability && providerProtocolOptions.some((option) => option.value === primaryCapability.type)
|
||||
? primaryCapability.type as GatewayProviderProtocol
|
||||
: fallbackProtocol;
|
||||
const baseUrl = fallbackBaseUrl;
|
||||
|
||||
const keySafetyIssue = providerApiKeySafetyIssue({
|
||||
@@ -1548,7 +1556,6 @@ function App() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingProvider = providerEditIndex !== undefined ? draftConfig.Providers[providerEditIndex] : undefined;
|
||||
const providerId = existingProvider?.id ?? providerNameSlug(providerName);
|
||||
const provider: GatewayProviderConfig = {
|
||||
api_base_url: normalizeProviderBaseUrl(baseUrl),
|
||||
@@ -1820,6 +1827,7 @@ function App() {
|
||||
values[virtualModelEditIndex] = profile;
|
||||
}
|
||||
config.virtualModelProfiles = values;
|
||||
config.mediaTools.enabled = virtualModelProfilesUseMediaTools(values);
|
||||
const existingMcpServers = [...(config.agent?.mcpServers ?? [])];
|
||||
const replacementIndex = previousMcpServerName
|
||||
? existingMcpServers.findIndex((server) => server.name === previousMcpServerName)
|
||||
@@ -1854,6 +1862,7 @@ function App() {
|
||||
}
|
||||
values[index] = { ...item, enabled };
|
||||
config.virtualModelProfiles = values;
|
||||
config.mediaTools.enabled = virtualModelProfilesUseMediaTools(values);
|
||||
return config;
|
||||
});
|
||||
}
|
||||
@@ -1861,6 +1870,7 @@ function App() {
|
||||
function removeVirtualModel(index: number) {
|
||||
updateConfig((config) => {
|
||||
config.virtualModelProfiles = (config.virtualModelProfiles ?? []).filter((_, itemIndex) => itemIndex !== index);
|
||||
config.mediaTools.enabled = virtualModelProfilesUseMediaTools(config.virtualModelProfiles);
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1188,6 +1188,7 @@ function LocalAgentProviderImportPanel({
|
||||
...accountDraft,
|
||||
apiKey: result.provider.apiKey ?? "",
|
||||
baseUrl: result.provider.baseUrl,
|
||||
capabilities: result.provider.capabilities ?? [],
|
||||
credentials: [],
|
||||
icon: result.provider.icon?.trim() || localAgentProviderIconUrls[candidate.kind] || "",
|
||||
modelDescriptions: result.provider.modelDescriptions,
|
||||
@@ -1718,8 +1719,9 @@ export function AddProviderForm({
|
||||
<div className="space-y-1.5">
|
||||
{protocolProbeRows.map((item) => {
|
||||
const available = item.supported;
|
||||
const selectable = item.supported && selectableProtocols.includes(item.protocol);
|
||||
const checked = selectable && draft.selectedProtocols.includes(item.protocol);
|
||||
const selectableProtocol = selectableProtocols.find((protocol) => protocol === item.protocol);
|
||||
const selectable = item.supported && Boolean(selectableProtocol);
|
||||
const checked = Boolean(selectableProtocol && draft.selectedProtocols.includes(selectableProtocol));
|
||||
const itemKey = `${item.protocol}-${item.endpoint}`;
|
||||
return (
|
||||
<div className="grid grid-cols-[20px_minmax(118px,1fr)_minmax(88px,max-content)] items-center gap-2 text-[11px]" key={itemKey}>
|
||||
@@ -1728,13 +1730,13 @@ export function AddProviderForm({
|
||||
checked={checked}
|
||||
disabled={!selectable}
|
||||
onCheckedChange={() => {
|
||||
if (!selectable) {
|
||||
if (!selectableProtocol) {
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
selectedProtocols: checked
|
||||
? draft.selectedProtocols.filter((protocol) => protocol !== item.protocol)
|
||||
: uniqueProviderProtocols([...draft.selectedProtocols, item.protocol])
|
||||
? draft.selectedProtocols.filter((protocol) => protocol !== selectableProtocol)
|
||||
: uniqueProviderProtocols([...draft.selectedProtocols, selectableProtocol])
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
cn, createMcpServerDraftFromConfig, createRouteModelOptions, defaultFusionWebSearchProvider, Dialog, DialogBody, DialogContent, DialogFooter,
|
||||
DialogHeader, DialogTitle, ExtensionInstallDraft, Field, FolderOpen, formatPluginDependencies,
|
||||
createFusionWebSearchEnvRows, createKeyValueDraftRow, customFusionToolName, fusionToolExecutionFlagsFromTools, fusionToolOptions,
|
||||
fusionWebSearchProviderOptions, GatewayMcpServerConfig, GatewayMcpToolInfo, GatewayProviderConfig, Input, isBuiltInFusionToolName, isFusionVisionToolName, isFusionWebSearchToolName, KeyValueRowsControl, LoaderCircle,
|
||||
fusionWebSearchProviderOptions, GatewayMcpServerConfig, GatewayMcpToolInfo, GatewayProviderConfig, Input, isBuiltInFusionToolName, isFusionImageGenerationToolName, isFusionVideoGenerationToolName, isFusionVisionToolName, isFusionWebSearchToolName, KeyValueRowsControl, LoaderCircle,
|
||||
mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions,
|
||||
mcpStdioMessageModeOptions, motion, normalizeFusionToolName, Pencil,
|
||||
PluginMarketplaceEntry, Plus, PopoverContent, RouteTargetControl, Search, selectedFusionToolNames,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type KeyValueDraftRow,
|
||||
VirtualModelProfileConfig, virtualModelToolSummary, X
|
||||
} from "../shared/index";
|
||||
import { createGrokMediaModelOptions } from "@ccr/core/media/models";
|
||||
|
||||
const virtualModelTableGridClass = "grid-cols-[minmax(180px,0.9fr)_minmax(220px,1.1fr)_minmax(220px,1.1fr)_minmax(170px,0.85fr)_112px_96px]";
|
||||
const virtualModelTableMinWidthClass = "min-w-[1100px]";
|
||||
@@ -174,6 +175,41 @@ export function VirtualModelsView({
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaModelConfigurationPanel({
|
||||
draft,
|
||||
kind,
|
||||
modelOptions,
|
||||
onChange
|
||||
}: {
|
||||
draft: VirtualModelDraft;
|
||||
kind: "image" | "video";
|
||||
modelOptions: ReturnType<typeof createRouteModelOptions>;
|
||||
onChange: (patch: Partial<VirtualModelDraft>) => void;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const value = kind === "image" ? draft.imageGenerationModel : draft.videoGenerationModel;
|
||||
const options = useMemo(() => {
|
||||
const values = [...modelOptions];
|
||||
if (value && !values.some((option) => option.value === value)) {
|
||||
values.push({ label: value, value });
|
||||
}
|
||||
return values;
|
||||
}, [modelOptions, t, value]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 rounded-md border border-border/70 bg-muted/25 p-3">
|
||||
<Field label={t(kind === "image" ? "Image model" : "Video model")}>
|
||||
<SelectControl
|
||||
onChange={(model) => onChange(kind === "image" ? { imageGenerationModel: model } : { videoGenerationModel: model })}
|
||||
options={options}
|
||||
value={value}
|
||||
/>
|
||||
</Field>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">{t("CCR routes media through the selected ai-gateway provider. Imported Grok Agents reuse their existing login automatically.")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VirtualModelDialog({
|
||||
canSubmit,
|
||||
draft,
|
||||
@@ -198,6 +234,8 @@ export function VirtualModelDialog({
|
||||
const t = useAppText();
|
||||
const formatError = useAppErrorText();
|
||||
const modelOptions = useMemo(() => createRouteModelOptions(providers), [providers]);
|
||||
const imageModelOptions = useMemo(() => createGrokMediaModelOptions(providers, "image"), [providers]);
|
||||
const videoModelOptions = useMemo(() => createGrokMediaModelOptions(providers, "video"), [providers]);
|
||||
const selectedTools = selectedFusionToolNames(draft.toolsText);
|
||||
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
|
||||
const [customMcpDialogDraft, setCustomMcpDialogDraft] = useState(draft.customMcpServer);
|
||||
@@ -387,39 +425,41 @@ export function VirtualModelDialog({
|
||||
<Field label={t("Base model")}>
|
||||
<RouteTargetControl modelOptions={modelOptions} onChange={(fixedModel) => onChange({ fixedModel })} value={draft.fixedModel} />
|
||||
</Field>
|
||||
<div className="flex h-5 items-center justify-center font-mono text-[13px] font-semibold text-muted-foreground">+</div>
|
||||
<Field label={t("Tools")}>
|
||||
<FusionToolsListControl
|
||||
adding={addingFusionTool}
|
||||
draft={draft}
|
||||
mcpServers={availableMcpServers}
|
||||
mcpToolStateByServer={mcpToolStateByServer}
|
||||
modelOptions={modelOptions}
|
||||
onAddCustomMcpTool={openCustomMcpDialog}
|
||||
onAddTool={() => setAddingFusionTool(true)}
|
||||
onAppendTool={appendFusionTool}
|
||||
onCancelAddTool={() => setAddingFusionTool(false)}
|
||||
onChange={onChange}
|
||||
onChangeTool={updateFusionTool}
|
||||
onDiscoverMcpTools={(server, force) => {
|
||||
if (server) {
|
||||
void discoverMcpServerTools(server, force);
|
||||
return;
|
||||
}
|
||||
discoverVisibleMcpServers();
|
||||
}}
|
||||
onRemoveTool={removeFusionTool}
|
||||
selectedMcpServerName={draft.customMcpServer.name}
|
||||
values={selectedTools}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex h-5 items-center justify-center font-mono text-[13px] font-semibold text-muted-foreground">+</div>
|
||||
<Field label={t("Tools")}>
|
||||
<FusionToolsListControl
|
||||
adding={addingFusionTool}
|
||||
draft={draft}
|
||||
imageModelOptions={imageModelOptions}
|
||||
mcpServers={availableMcpServers}
|
||||
mcpToolStateByServer={mcpToolStateByServer}
|
||||
modelOptions={modelOptions}
|
||||
onAddCustomMcpTool={openCustomMcpDialog}
|
||||
onAddTool={() => setAddingFusionTool(true)}
|
||||
onAppendTool={appendFusionTool}
|
||||
onCancelAddTool={() => setAddingFusionTool(false)}
|
||||
onChange={onChange}
|
||||
onChangeTool={updateFusionTool}
|
||||
onDiscoverMcpTools={(server, force) => {
|
||||
if (server) {
|
||||
void discoverMcpServerTools(server, force);
|
||||
return;
|
||||
}
|
||||
discoverVisibleMcpServers();
|
||||
}}
|
||||
onRemoveTool={removeFusionTool}
|
||||
selectedMcpServerName={draft.customMcpServer.name}
|
||||
videoModelOptions={videoModelOptions}
|
||||
values={selectedTools}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">{t(error)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">{t(error)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
@@ -702,14 +742,18 @@ function CustomMcpToolDialog({
|
||||
|
||||
function FusionToolConfigurationPanel({
|
||||
draft,
|
||||
imageModelOptions,
|
||||
modelOptions,
|
||||
onChange,
|
||||
toolName
|
||||
toolName,
|
||||
videoModelOptions
|
||||
}: {
|
||||
draft: VirtualModelDraft;
|
||||
imageModelOptions: ReturnType<typeof createGrokMediaModelOptions>;
|
||||
modelOptions: ReturnType<typeof createRouteModelOptions>;
|
||||
onChange: (patch: Partial<VirtualModelDraft>) => void;
|
||||
toolName: string;
|
||||
videoModelOptions: ReturnType<typeof createGrokMediaModelOptions>;
|
||||
}) {
|
||||
if (isFusionVisionToolName(toolName)) {
|
||||
return <VisionToolConfigurationPanel draft={draft} modelOptions={modelOptions} onChange={onChange} />;
|
||||
@@ -717,6 +761,12 @@ function FusionToolConfigurationPanel({
|
||||
if (isFusionWebSearchToolName(toolName)) {
|
||||
return <WebSearchToolConfigurationPanel draft={draft} onChange={onChange} />;
|
||||
}
|
||||
if (isFusionImageGenerationToolName(toolName)) {
|
||||
return <MediaModelConfigurationPanel draft={draft} kind="image" modelOptions={imageModelOptions} onChange={onChange} />;
|
||||
}
|
||||
if (isFusionVideoGenerationToolName(toolName)) {
|
||||
return <MediaModelConfigurationPanel draft={draft} kind="video" modelOptions={videoModelOptions} onChange={onChange} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -743,6 +793,7 @@ function VisionToolConfigurationPanel({
|
||||
function FusionToolsListControl({
|
||||
adding,
|
||||
draft,
|
||||
imageModelOptions,
|
||||
mcpServers,
|
||||
mcpToolStateByServer,
|
||||
modelOptions,
|
||||
@@ -755,10 +806,12 @@ function FusionToolsListControl({
|
||||
onDiscoverMcpTools,
|
||||
onRemoveTool,
|
||||
selectedMcpServerName,
|
||||
videoModelOptions,
|
||||
values
|
||||
}: {
|
||||
adding: boolean;
|
||||
draft: VirtualModelDraft;
|
||||
imageModelOptions: ReturnType<typeof createGrokMediaModelOptions>;
|
||||
mcpServers: GatewayMcpServerConfig[];
|
||||
mcpToolStateByServer: Record<string, {
|
||||
error?: string;
|
||||
@@ -775,6 +828,7 @@ function FusionToolsListControl({
|
||||
onDiscoverMcpTools: (server?: GatewayMcpServerConfig, force?: boolean) => void;
|
||||
onRemoveTool: (index: number) => void;
|
||||
selectedMcpServerName: string;
|
||||
videoModelOptions: ReturnType<typeof createGrokMediaModelOptions>;
|
||||
values: string[];
|
||||
}) {
|
||||
const t = useAppText();
|
||||
@@ -810,9 +864,11 @@ function FusionToolsListControl({
|
||||
</div>
|
||||
<FusionToolConfigurationPanel
|
||||
draft={draft}
|
||||
imageModelOptions={imageModelOptions}
|
||||
modelOptions={modelOptions}
|
||||
onChange={onChange}
|
||||
toolName={value}
|
||||
videoModelOptions={videoModelOptions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -897,7 +953,7 @@ function FusionToolSelectControl({
|
||||
const selectedServer = selectedMcpServerName
|
||||
? mcpServers.find((server) => server.name === selectedMcpServerName)
|
||||
: mcpServers.find((server) => mcpToolStateByServer[server.name]?.tools?.some((tool) => tool.name === normalizedValue));
|
||||
const selectedLabel = selected?.label ?? (selectedServer && normalizedValue ? `${selectedServer.name} / ${normalizedValue}` : normalizedValue || t("Select tool"));
|
||||
const selectedLabel = selected ? t(selected.label) : (selectedServer && normalizedValue ? `${selectedServer.name} / ${normalizedValue}` : normalizedValue || t("Select tool"));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
@@ -1033,7 +1089,7 @@ function FusionToolSelectControl({
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-semibold">{option.label}</span>
|
||||
<span className="block truncate text-[12px] font-semibold">{t(option.label)}</span>
|
||||
<span className={cn("mt-0.5 block text-[11px] leading-4", selectedOption ? "text-primary/80" : "text-muted-foreground")}>
|
||||
{t(option.description)}
|
||||
</span>
|
||||
|
||||
@@ -407,6 +407,7 @@ export function normalizeConfig(config: AppConfig): AppConfig {
|
||||
...(config.gateway || {}),
|
||||
coreHost: fallbackConfig.gateway.coreHost
|
||||
},
|
||||
mediaTools: normalizeMediaToolsConfig(config.mediaTools),
|
||||
launchAtLogin: Boolean(config.launchAtLogin),
|
||||
observability: normalizeObservabilityConfig(config.observability),
|
||||
proxy: normalizeProxyConfig(config.proxy),
|
||||
@@ -510,3 +511,20 @@ export function normalizeToolHubConfig(config: Partial<AppConfig["toolHub"]> | u
|
||||
requestTimeoutMs
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMediaToolsConfig(config: Partial<AppConfig["mediaTools"]> | undefined): AppConfig["mediaTools"] {
|
||||
const clampInteger = (value: unknown, fallback: number, min: number, max: number) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), min), max) : fallback;
|
||||
return {
|
||||
...fallbackConfig.mediaTools,
|
||||
...(config || {}),
|
||||
allowedInputRoots: Array.isArray(config?.allowedInputRoots)
|
||||
? config.allowedInputRoots.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim())
|
||||
: [],
|
||||
artifactTtlHours: clampInteger(config?.artifactTtlHours, fallbackConfig.mediaTools.artifactTtlHours, 1, 720),
|
||||
enabled: Boolean(config?.enabled),
|
||||
jobTimeoutMs: clampInteger(config?.jobTimeoutMs, fallbackConfig.mediaTools.jobTimeoutMs, 30000, 3600000),
|
||||
maxImageConcurrency: clampInteger(config?.maxImageConcurrency, fallbackConfig.mediaTools.maxImageConcurrency, 1, 8),
|
||||
maxVideoConcurrency: clampInteger(config?.maxVideoConcurrency, fallbackConfig.mediaTools.maxVideoConcurrency, 1, 4)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,6 +463,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.",
|
||||
"Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
|
||||
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
|
||||
"Image generation": "Image generation",
|
||||
"Video generation": "Video generation",
|
||||
"Generate and edit images with a media-capable provider model.": "Generate and edit images with a media-capable provider model.",
|
||||
"Generate and manage asynchronous videos with a media-capable provider model.": "Generate and manage asynchronous videos with a media-capable provider model.",
|
||||
"Image model": "Image model",
|
||||
"Video model": "Video model",
|
||||
"API models reuse the selected provider's endpoint and credentials.": "API models reuse the selected provider's endpoint and credentials.",
|
||||
"CCR routes media through the selected ai-gateway provider. Imported Grok Agents reuse their existing login automatically.": "CCR routes media through the selected ai-gateway provider. Imported Grok Agents reuse their existing login automatically.",
|
||||
"Image generation model is required.": "Image generation model is required.",
|
||||
"Video generation model is required.": "Video generation model is required.",
|
||||
"Web Search": "Web Search",
|
||||
"New model": "New model",
|
||||
"New model is required.": "New model is required.",
|
||||
@@ -1387,8 +1397,6 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Match web search": "匹配网页搜索",
|
||||
"Max tool calls": "最大工具调用",
|
||||
"Max tool calls must be greater than zero.": "最大工具调用必须大于 0。",
|
||||
"Max turns": "最大轮次",
|
||||
"Max turns must be greater than zero.": "最大轮次必须大于 0。",
|
||||
"Fusion combines a model with another model or tools into a new model.": "将模型和模型/工具组合起来成为一个新的模型。",
|
||||
"Fusion example": "例如:GLM 5.2 + GLM 5V Turbo = GLM 5.2V",
|
||||
"Vision tool configuration": "视觉工具配置",
|
||||
@@ -1448,6 +1456,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "通用图片理解工具,支持 OCR、截图分析、图表解读、UI 对比、错误诊断等多图任务。",
|
||||
"Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。",
|
||||
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持隐藏内置浏览器搜索,以及 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。",
|
||||
"Image generation": "图片生成",
|
||||
"Video generation": "视频生成",
|
||||
"Generate and edit images with a media-capable provider model.": "使用支持媒体协议的供应商模型生成和编辑图片。",
|
||||
"Generate and manage asynchronous videos with a media-capable provider model.": "使用支持媒体协议的供应商模型生成并管理异步视频任务。",
|
||||
"Image model": "图片模型",
|
||||
"Video model": "视频模型",
|
||||
"API models reuse the selected provider's endpoint and credentials.": "API 模型复用所选供应商的地址和凭据。",
|
||||
"CCR routes media through the selected ai-gateway provider. Imported Grok Agents reuse their existing login automatically.": "CCR 通过 ai-gateway 路由到所选媒体供应商;导入的 Grok Agent 会自动复用现有登录态。",
|
||||
"Image generation model is required.": "请选择图片生成模型。",
|
||||
"Video generation model is required.": "请选择视频生成模型。",
|
||||
"Web Search": "网页搜索",
|
||||
"New model": "新模型",
|
||||
"New model is required.": "新模型不能为空。",
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BUILTIN_FUSION_TOOL_SERVER_NAME,
|
||||
BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME,
|
||||
BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME,
|
||||
BUILTIN_FUSION_VISION_TOOL_NAME,
|
||||
BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME,
|
||||
OVERVIEW_WIDGET_SIZE_VALUES
|
||||
@@ -279,6 +281,16 @@ export const fusionToolOptions: Array<{ description: string; label: string; valu
|
||||
description: "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
|
||||
label: `${BUILTIN_FUSION_TOOL_SERVER_NAME} / ${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`,
|
||||
value: BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME
|
||||
},
|
||||
{
|
||||
description: "Generate and edit images with a media-capable provider model.",
|
||||
label: `${BUILTIN_FUSION_TOOL_SERVER_NAME} / ${BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME}`,
|
||||
value: BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME
|
||||
},
|
||||
{
|
||||
description: "Generate and manage asynchronous videos with a media-capable provider model.",
|
||||
label: `${BUILTIN_FUSION_TOOL_SERVER_NAME} / ${BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME}`,
|
||||
value: BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ import type {
|
||||
BotHandoffScanTarget,
|
||||
GatewayProviderConfig,
|
||||
GatewayProviderCapability,
|
||||
GatewayProviderCapabilityProtocol,
|
||||
GatewayPluginAppConfig,
|
||||
GatewayProviderConnectivityCheckModelResult,
|
||||
GatewayProviderConnectivityCheckReport,
|
||||
@@ -930,6 +931,7 @@ export function createProviderDraftFromDeepLinkPayload(
|
||||
...accountDraft,
|
||||
apiKey: payload.apiKey?.trim() || "",
|
||||
baseUrl,
|
||||
capabilities: payload.capabilities ?? [],
|
||||
credentials: [],
|
||||
icon: payload.icon?.trim() || "",
|
||||
modelDescriptions: modelDescriptionsForModels(payload.modelDescriptions, models),
|
||||
@@ -986,7 +988,10 @@ export function createProviderConfigFromDeepLink(
|
||||
throw new Error(accountKeySafetyIssue.message);
|
||||
}
|
||||
|
||||
const capabilities = providerCapabilitiesForProtocols(payload.baseUrl, [protocol], probe);
|
||||
const capabilities = mergeProviderCapabilities(
|
||||
payload.capabilities ?? [],
|
||||
providerCapabilitiesForProtocols(payload.baseUrl, [protocol], probe)
|
||||
);
|
||||
|
||||
return {
|
||||
account: cloneProviderAccountConfig(account),
|
||||
@@ -1019,6 +1024,7 @@ export function createProviderDraft(providers: GatewayProviderConfig[]): AddProv
|
||||
...accountDraft,
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
capabilities: [],
|
||||
credentials: [],
|
||||
icon: "",
|
||||
modelDescriptions: undefined,
|
||||
@@ -1044,6 +1050,7 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
|
||||
...accountDraft,
|
||||
apiKey: providerApiKey(provider),
|
||||
baseUrl,
|
||||
capabilities: provider.capabilities ?? [],
|
||||
credentials: (provider.credentials ?? []).map(providerCredentialDraftFromConfig),
|
||||
icon: provider.icon ?? "",
|
||||
modelDescriptions: modelDescriptionsForModels(provider.modelDescriptions, provider.models),
|
||||
@@ -1776,13 +1783,21 @@ export function providerDraftSafetyIssue(draft: AddProviderDraft, baseUrl = draf
|
||||
|
||||
export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeCandidate[] {
|
||||
const preset = findProviderPreset(draft.presetId);
|
||||
const protocols = providerProtocolOptions.map((option) => option.value);
|
||||
const mediaProtocols: GatewayProviderCapabilityProtocol[] = [
|
||||
"openai_image_generations",
|
||||
"openai_video_generations"
|
||||
];
|
||||
const chatProtocols = providerProtocolOptions.map((option) => option.value);
|
||||
const customProtocols: GatewayProviderCapabilityProtocol[] = [
|
||||
...chatProtocols,
|
||||
...mediaProtocols
|
||||
];
|
||||
if (preset) {
|
||||
const probeAllProtocols = preset.endpoints.length === 1;
|
||||
return preset.endpoints.map((endpoint) => ({
|
||||
...endpoint,
|
||||
declaredProtocols: endpoint.protocols,
|
||||
protocols: probeAllProtocols ? protocols : endpoint.protocols,
|
||||
protocols: probeAllProtocols ? chatProtocols : endpoint.protocols,
|
||||
source: "preset"
|
||||
}));
|
||||
}
|
||||
@@ -1790,7 +1805,7 @@ export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeC
|
||||
return [
|
||||
{
|
||||
baseUrl: draft.baseUrl.trim(),
|
||||
protocols,
|
||||
protocols: customProtocols,
|
||||
source: "custom"
|
||||
}
|
||||
];
|
||||
@@ -1965,6 +1980,24 @@ export function mergeProviderCapabilities(...groups: GatewayProviderCapability[]
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
export function providerCapabilitiesForSave(
|
||||
currentCapabilities: GatewayProviderCapability[],
|
||||
preservedCapabilities: GatewayProviderCapability[],
|
||||
existingBaseUrl: string | undefined,
|
||||
nextBaseUrl: string
|
||||
): GatewayProviderCapability[] {
|
||||
const normalizedExistingBaseUrl = existingBaseUrl === undefined
|
||||
? undefined
|
||||
: normalizeProviderBaseUrl(existingBaseUrl) || existingBaseUrl.trim();
|
||||
const normalizedNextBaseUrl = normalizeProviderBaseUrl(nextBaseUrl) || nextBaseUrl.trim();
|
||||
const preserveExisting = normalizedExistingBaseUrl === undefined ||
|
||||
normalizedExistingBaseUrl === normalizedNextBaseUrl;
|
||||
return mergeProviderCapabilities(
|
||||
currentCapabilities,
|
||||
...(preserveExisting ? [preservedCapabilities] : [])
|
||||
);
|
||||
}
|
||||
|
||||
export function providerGlobalBaseUrlForProbe(
|
||||
inputBaseUrl: string,
|
||||
_probe: GatewayProviderProbeResult | undefined,
|
||||
@@ -2019,7 +2052,10 @@ export function providerCapabilitiesForProtocols(
|
||||
})
|
||||
.filter((item): item is GatewayProviderCapability => Boolean(item));
|
||||
|
||||
return mergeProviderCapabilities(selectedCapabilities);
|
||||
const detectedMediaCapabilities = detectedCapabilities.filter((capability) =>
|
||||
capability.type === "openai_image_generations" || capability.type === "openai_video_generations"
|
||||
);
|
||||
return mergeProviderCapabilities(selectedCapabilities, detectedMediaCapabilities);
|
||||
}
|
||||
|
||||
export function applyProviderProbeResult(draft: AddProviderDraft, probe: GatewayProviderProbeResult): AddProviderDraft {
|
||||
|
||||
@@ -421,6 +421,7 @@ export type AddProviderDraft = {
|
||||
accountRefreshIntervalMs: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
capabilities: GatewayProviderCapability[];
|
||||
credentials: ProviderCredentialDraft[];
|
||||
icon: string;
|
||||
modelDescriptions?: Record<string, string>;
|
||||
@@ -601,6 +602,7 @@ export type ClaudeDesignRoutingDraft = {
|
||||
export type VirtualModelClientToolsPolicy = "allow" | "deny";
|
||||
export type VirtualModelMatchMode = "alias" | "prefix" | "suffix";
|
||||
export const fusionCustomToolMetadataKey = "fusionTool";
|
||||
export const fusionMediaMetadataKey = "fusionMedia";
|
||||
export const fusionVisionMetadataKey = "fusionVision";
|
||||
export const fusionWebSearchMetadataKey = "fusionWebSearch";
|
||||
|
||||
@@ -624,6 +626,7 @@ export type VirtualModelDraft = {
|
||||
fixedModel: string;
|
||||
id: string;
|
||||
includeInGatewayModels: boolean;
|
||||
imageGenerationModel: string;
|
||||
instructionsAppend: string;
|
||||
instructionsPrepend: string;
|
||||
instructionsReplace: string;
|
||||
@@ -632,8 +635,6 @@ export type VirtualModelDraft = {
|
||||
matchMultimodal: boolean;
|
||||
matchMode: VirtualModelMatchMode;
|
||||
matchWebSearch: boolean;
|
||||
maxToolCalls: string;
|
||||
maxTurns: string;
|
||||
prefixesText: string;
|
||||
suffixesText: string;
|
||||
toolChoiceText: string;
|
||||
@@ -642,6 +643,7 @@ export type VirtualModelDraft = {
|
||||
customMcpServer: McpServerDraft;
|
||||
customToolName: string;
|
||||
visionModel: string;
|
||||
videoGenerationModel: string;
|
||||
webSearchEnvRows: KeyValueDraftRow[];
|
||||
webSearchProvider: VirtualModelFusionWebSearchProvider;
|
||||
executionMode: VirtualModelExecutionMode;
|
||||
|
||||
@@ -121,8 +121,23 @@ import trayOrangeIconUrl from "@/assets/tray-orange.png";
|
||||
import trayVioletIconUrl from "@/assets/tray-violet.png";
|
||||
import {
|
||||
BUILTIN_FUSION_TOOL_SERVER_NAME,
|
||||
BUILTIN_FUSION_GROK_MEDIA_TOOL_NAME,
|
||||
BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME,
|
||||
BUILTIN_FUSION_VISION_TOOL_NAME,
|
||||
BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME,
|
||||
BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME,
|
||||
GROK_MEDIA_CAPABILITIES_TOOL_NAME,
|
||||
GROK_MEDIA_FUSION_TOOL_NAMES,
|
||||
GROK_MEDIA_IMAGE_EDIT_TOOL_NAME,
|
||||
GROK_MEDIA_IMAGE_GENERATE_TOOL_NAME,
|
||||
GROK_MEDIA_JOB_CANCEL_TOOL_NAME,
|
||||
GROK_MEDIA_JOB_GET_TOOL_NAME,
|
||||
GROK_MEDIA_VIDEO_START_TOOL_NAME,
|
||||
MEDIA_IMAGE_EDIT_TOOL_PREFIX,
|
||||
MEDIA_IMAGE_GENERATE_TOOL_PREFIX,
|
||||
MEDIA_JOB_CANCEL_TOOL_PREFIX,
|
||||
MEDIA_JOB_GET_TOOL_PREFIX,
|
||||
MEDIA_VIDEO_START_TOOL_PREFIX,
|
||||
CLAUDE_CODE_DEFAULT_ENV,
|
||||
DEFAULT_OVERVIEW_WIDGETS,
|
||||
DEFAULT_TRAY_COMPONENT_VARIANTS,
|
||||
@@ -228,6 +243,7 @@ import type {
|
||||
VirtualModelBaseModelMode,
|
||||
VirtualModelExecutionMode,
|
||||
VirtualModelFusionCustomToolConfig,
|
||||
VirtualModelFusionMediaConfig,
|
||||
VirtualModelFusionVisionConfig,
|
||||
VirtualModelFusionWebSearchConfig,
|
||||
VirtualModelFusionWebSearchProvider,
|
||||
@@ -375,14 +391,17 @@ import type { MotionSafeDivAttributes } from "./motion";
|
||||
import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common";
|
||||
import { sanitizeConfigId } from "./extensions";
|
||||
import { createRouteModelOptions, numberValue } from "./providers";
|
||||
import { createGrokMediaModelOptions, migrateLegacyGrokMediaModelSelector } from "@ccr/core/media/models";
|
||||
import { clampNumber } from "./services";
|
||||
import { fusionCustomToolMetadataKey, fusionVisionMetadataKey, fusionWebSearchMetadataKey } from "./types";
|
||||
import { fusionCustomToolMetadataKey, fusionMediaMetadataKey, fusionVisionMetadataKey, fusionWebSearchMetadataKey } from "./types";
|
||||
import type { KeyValueDraftRow, McpServerDraft, VirtualModelDraft, VirtualModelMatchMode, VirtualModelToolDraft } from "./types";
|
||||
|
||||
export function createVirtualModelDraft(config: AppConfig): VirtualModelDraft {
|
||||
const profiles = config.virtualModelProfiles ?? [];
|
||||
const key = uniqueVirtualModelKey(profiles);
|
||||
const defaultModel = createRouteModelOptions(config.Providers)[0]?.value ?? "";
|
||||
const defaultImageModel = createGrokMediaModelOptions(config.Providers, "image")[0]?.value ?? "";
|
||||
const defaultVideoModel = createGrokMediaModelOptions(config.Providers, "video")[0]?.value ?? "";
|
||||
return {
|
||||
baseModelMode: "fixed",
|
||||
clientToolsPolicy: "allow",
|
||||
@@ -397,6 +416,7 @@ export function createVirtualModelDraft(config: AppConfig): VirtualModelDraft {
|
||||
fixedModel: defaultModel,
|
||||
id: uniqueVirtualModelId(profiles, key),
|
||||
includeInGatewayModels: true,
|
||||
imageGenerationModel: defaultImageModel,
|
||||
instructionsAppend: "",
|
||||
instructionsPrepend: "",
|
||||
instructionsReplace: "",
|
||||
@@ -405,14 +425,13 @@ export function createVirtualModelDraft(config: AppConfig): VirtualModelDraft {
|
||||
matchMultimodal: true,
|
||||
matchMode: "alias",
|
||||
matchWebSearch: false,
|
||||
maxToolCalls: "8",
|
||||
maxTurns: "6",
|
||||
prefixesText: "",
|
||||
suffixesText: "",
|
||||
toolChoiceText: "",
|
||||
tools: [],
|
||||
toolsText: BUILTIN_FUSION_VISION_TOOL_NAME,
|
||||
visionModel: defaultModel,
|
||||
videoGenerationModel: defaultVideoModel,
|
||||
webSearchEnvRows: createFusionWebSearchEnvRows(defaultFusionWebSearchProvider),
|
||||
webSearchProvider: defaultFusionWebSearchProvider,
|
||||
executionMode: "tool_loop"
|
||||
@@ -428,10 +447,12 @@ export function createVirtualModelDraftFromProfile(profile: VirtualModelProfileC
|
||||
const toolDrafts = (profile.tools ?? []).map((tool, index) => createVirtualModelToolDraft(tool, index));
|
||||
const visionConfig = fusionVisionConfigFromProfile(profile);
|
||||
const webSearchConfig = fusionWebSearchConfigFromProfile(profile);
|
||||
const mediaConfig = fusionMediaConfigFromProfile(profile);
|
||||
const selectedToolNames = selectedFusionToolNamesFromProfile(toolDrafts, profile);
|
||||
const flags = fusionToolExecutionFlagsFromTools(selectedToolNames);
|
||||
const routeModelOptions = createRouteModelOptions(config?.Providers ?? []);
|
||||
const defaultVisionModel = routeModelOptions[0]?.value ?? "";
|
||||
const providers = config?.Providers ?? [];
|
||||
const customToolConfig = fusionCustomToolConfigFromProfile(profile);
|
||||
const customToolName = selectedToolNames.find((toolName) => !isBuiltInFusionToolName(toolName)) ?? customFusionToolName;
|
||||
const configuredMcpServers = config?.agent?.mcpServers ?? [];
|
||||
@@ -458,6 +479,7 @@ export function createVirtualModelDraftFromProfile(profile: VirtualModelProfileC
|
||||
fixedModel: profile.baseModel?.fixedModel ?? "",
|
||||
id: profile.id,
|
||||
includeInGatewayModels: profile.materialization?.includeInGatewayModels !== false,
|
||||
imageGenerationModel: migrateLegacyGrokMediaModelSelector(providers, mediaConfig?.imageModelSelector, "image") ?? "",
|
||||
instructionsAppend: profile.instructions?.append ?? "",
|
||||
instructionsPrepend: profile.instructions?.prepend ?? "",
|
||||
instructionsReplace: profile.instructions?.replace ?? "",
|
||||
@@ -466,14 +488,13 @@ export function createVirtualModelDraftFromProfile(profile: VirtualModelProfileC
|
||||
matchMultimodal: flags.matchMultimodal,
|
||||
matchMode: "alias",
|
||||
matchWebSearch: flags.matchWebSearch,
|
||||
maxToolCalls: String(profile.execution?.maxToolCalls ?? 8),
|
||||
maxTurns: String(profile.execution?.maxTurns ?? 6),
|
||||
prefixesText: (profile.match?.prefixes ?? []).join(", "),
|
||||
suffixesText: (profile.match?.suffixes ?? []).join(", "),
|
||||
toolChoiceText: formatVirtualModelToolChoice(profile.toolChoice),
|
||||
tools: toolDrafts,
|
||||
toolsText: selectedToolNames.join(", "),
|
||||
visionModel: visionConfig?.modelSelector ?? visionConfig?.model ?? defaultVisionModel,
|
||||
videoGenerationModel: migrateLegacyGrokMediaModelSelector(providers, mediaConfig?.videoModelSelector, "video") ?? "",
|
||||
webSearchEnvRows: createFusionWebSearchEnvRows(webSearchConfig?.provider ?? defaultFusionWebSearchProvider, keyValueRowsFromRecord(webSearchConfig?.env ?? {})),
|
||||
webSearchProvider: webSearchConfig?.provider ?? defaultFusionWebSearchProvider,
|
||||
executionMode: "tool_loop"
|
||||
@@ -628,6 +649,50 @@ export function fusionWebSearchToolName(key: string): string {
|
||||
return `${normalized || "fusion"}_web_search`;
|
||||
}
|
||||
|
||||
export function fusionMediaConfigFromProfile(profile: VirtualModelProfileConfig): VirtualModelFusionMediaConfig | undefined {
|
||||
const value = profile.metadata?.[fusionMediaMetadataKey];
|
||||
if (!isPlainRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const config: VirtualModelFusionMediaConfig = {
|
||||
imageEditToolName: stringValue(value.imageEditToolName),
|
||||
imageGenerateToolName: stringValue(value.imageGenerateToolName),
|
||||
imageModelSelector: stringValue(value.imageModelSelector),
|
||||
jobCancelToolName: stringValue(value.jobCancelToolName),
|
||||
jobGetToolName: stringValue(value.jobGetToolName),
|
||||
videoModelSelector: stringValue(value.videoModelSelector),
|
||||
videoStartToolName: stringValue(value.videoStartToolName)
|
||||
};
|
||||
return Object.values(config).some(Boolean) ? config : undefined;
|
||||
}
|
||||
|
||||
export function fusionMediaConfigFromDraft(draft: VirtualModelDraft, key: string): VirtualModelFusionMediaConfig | undefined {
|
||||
const selectedTools = selectedFusionToolNames(draft.toolsText);
|
||||
const imageEnabled = selectedTools.some(isFusionImageGenerationToolName);
|
||||
const videoEnabled = selectedTools.some(isFusionVideoGenerationToolName);
|
||||
if (!imageEnabled && !videoEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(imageEnabled ? {
|
||||
imageEditToolName: fusionMediaToolName(MEDIA_IMAGE_EDIT_TOOL_PREFIX, key),
|
||||
imageGenerateToolName: fusionMediaToolName(MEDIA_IMAGE_GENERATE_TOOL_PREFIX, key),
|
||||
imageModelSelector: draft.imageGenerationModel.trim()
|
||||
} : {}),
|
||||
...(videoEnabled ? {
|
||||
jobCancelToolName: fusionMediaToolName(MEDIA_JOB_CANCEL_TOOL_PREFIX, key),
|
||||
jobGetToolName: fusionMediaToolName(MEDIA_JOB_GET_TOOL_PREFIX, key),
|
||||
videoModelSelector: draft.videoGenerationModel.trim(),
|
||||
videoStartToolName: fusionMediaToolName(MEDIA_VIDEO_START_TOOL_PREFIX, key)
|
||||
} : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function fusionMediaToolName(prefix: string, key: string): string {
|
||||
const normalized = sanitizeConfigId(key).replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return `${prefix}_${normalized || "fusion"}`;
|
||||
}
|
||||
|
||||
export function fusionCustomToolConfigFromProfile(profile: VirtualModelProfileConfig): VirtualModelFusionCustomToolConfig | undefined {
|
||||
const value = profile.metadata?.[fusionCustomToolMetadataKey];
|
||||
if (!isPlainRecord(value)) {
|
||||
@@ -703,6 +768,12 @@ export function validateVirtualModelDraft(draft: VirtualModelDraft): string {
|
||||
if (flags.matchMultimodal && !draft.visionModel.trim()) {
|
||||
return "Vision model is required.";
|
||||
}
|
||||
if (selectedTools.some(isFusionImageGenerationToolName) && !draft.imageGenerationModel.trim()) {
|
||||
return "Image generation model is required.";
|
||||
}
|
||||
if (selectedTools.some(isFusionVideoGenerationToolName) && !draft.videoGenerationModel.trim()) {
|
||||
return "Video generation model is required.";
|
||||
}
|
||||
if (flags.matchWebSearch && !validateKeyValueRows(draft.webSearchEnvRows)) {
|
||||
return "Environment variable keys are required when values are set.";
|
||||
}
|
||||
@@ -727,24 +798,30 @@ export function virtualModelProfileFromDraft(
|
||||
const displayName = titleFromConfigKey(primaryMatchValue) || primaryMatchValue || draft.displayName.trim() || key;
|
||||
const fusionVisionConfig = fusionVisionConfigFromDraft(draft, id);
|
||||
const fusionWebSearchConfig = fusionWebSearchConfigFromDraft(draft, id);
|
||||
const fusionMediaConfig = fusionMediaConfigFromDraft(draft, id);
|
||||
const fusionCustomToolConfig = fusionCustomToolConfigFromDraft(draft);
|
||||
const selectedTools = selectedFusionToolNames(draft.toolsText);
|
||||
const toolNames = selectedTools.map((toolName) => {
|
||||
const toolNames = selectedTools.flatMap((toolName) => {
|
||||
if (fusionVisionConfig?.toolName && isFusionVisionToolName(toolName)) {
|
||||
return fusionVisionConfig.toolName;
|
||||
return [fusionVisionConfig.toolName];
|
||||
}
|
||||
if (fusionWebSearchConfig?.toolName && isFusionWebSearchToolName(toolName)) {
|
||||
return fusionWebSearchConfig.toolName;
|
||||
return [fusionWebSearchConfig.toolName];
|
||||
}
|
||||
return toolName;
|
||||
if (isFusionImageGenerationToolName(toolName) && fusionMediaConfig) {
|
||||
return [fusionMediaConfig.imageGenerateToolName, fusionMediaConfig.imageEditToolName].filter((name): name is string => Boolean(name));
|
||||
}
|
||||
if (isFusionVideoGenerationToolName(toolName) && fusionMediaConfig) {
|
||||
return [fusionMediaConfig.videoStartToolName, fusionMediaConfig.jobGetToolName, fusionMediaConfig.jobCancelToolName].filter((name): name is string => Boolean(name));
|
||||
}
|
||||
return [toolName];
|
||||
});
|
||||
const tools = virtualModelToolsFromDraft(draft, toolNames);
|
||||
const maxToolCalls = numberValue(draft.maxToolCalls);
|
||||
const maxTurns = numberValue(draft.maxTurns);
|
||||
const flags = fusionToolExecutionFlagsFromTools(toolNames);
|
||||
const metadata = {
|
||||
...(fusionVisionConfig ? { [fusionVisionMetadataKey]: fusionVisionConfig } : {}),
|
||||
...(fusionWebSearchConfig ? { [fusionWebSearchMetadataKey]: fusionWebSearchConfig } : {}),
|
||||
...(fusionMediaConfig ? { [fusionMediaMetadataKey]: fusionMediaConfig } : {}),
|
||||
...(fusionCustomToolConfig ? { [fusionCustomToolMetadataKey]: fusionCustomToolConfig } : {})
|
||||
};
|
||||
return {
|
||||
@@ -754,8 +831,6 @@ export function virtualModelProfileFromDraft(
|
||||
execution: {
|
||||
clientToolsPolicy: "allow",
|
||||
...flags,
|
||||
maxToolCalls: clampNumber(maxToolCalls || Math.max(tools.length, 1), 1, 50),
|
||||
maxTurns: clampNumber(maxTurns || 6, 1, 50),
|
||||
mode: "tool_loop",
|
||||
streamMode: "optimistic"
|
||||
},
|
||||
@@ -951,18 +1026,25 @@ export function virtualModelToolSummary(profile: VirtualModelProfileConfig): str
|
||||
}
|
||||
const visionConfig = fusionVisionConfigFromProfile(profile);
|
||||
const webSearchConfig = fusionWebSearchConfigFromProfile(profile);
|
||||
const mediaConfig = fusionMediaConfigFromProfile(profile);
|
||||
const customToolConfig = fusionCustomToolConfigFromProfile(profile);
|
||||
return profile.tools.map((tool) => {
|
||||
return uniqueStrings(profile.tools.map((tool) => {
|
||||
if (visionConfig?.toolName && tool.name === visionConfig.toolName) {
|
||||
return `${fusionToolDisplayName(BUILTIN_FUSION_VISION_TOOL_NAME)}${visionConfig.modelSelector || visionConfig.model ? ` (${visionConfig.modelSelector || visionConfig.model})` : ""}`;
|
||||
}
|
||||
if (webSearchConfig?.toolName && tool.name === webSearchConfig.toolName) {
|
||||
return `${fusionToolDisplayName(BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME)}${webSearchConfig.provider ? ` (${fusionWebSearchProviderLabel(webSearchConfig.provider)})` : ""}`;
|
||||
}
|
||||
if (isFusionImageGenerationToolName(tool.name)) {
|
||||
return `${fusionToolDisplayName(BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME)}${mediaConfig?.imageModelSelector ? ` (${mediaConfig.imageModelSelector})` : ""}`;
|
||||
}
|
||||
if (isFusionVideoGenerationToolName(tool.name)) {
|
||||
return `${fusionToolDisplayName(BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME)}${mediaConfig?.videoModelSelector ? ` (${mediaConfig.videoModelSelector})` : ""}`;
|
||||
}
|
||||
return customToolConfig?.mcpServerName
|
||||
? `${customToolConfig.mcpServerName} / ${fusionToolDisplayName(tool.name)}`
|
||||
: fusionToolDisplayName(tool.name);
|
||||
}).join(", ");
|
||||
})).join(", ");
|
||||
}
|
||||
|
||||
export function normalizeFusionToolName(name: string): string {
|
||||
@@ -979,7 +1061,38 @@ export function isFusionToolName(name: string): boolean {
|
||||
|
||||
export function isBuiltInFusionToolName(name: string): boolean {
|
||||
const normalized = normalizeFusionToolName(name);
|
||||
return isFusionVisionToolName(normalized) || isFusionWebSearchToolName(normalized);
|
||||
return isFusionVisionToolName(normalized) || isFusionWebSearchToolName(normalized) || isFusionImageGenerationToolName(normalized) || isFusionVideoGenerationToolName(normalized) || isGrokMediaFusionToolName(normalized);
|
||||
}
|
||||
|
||||
export function isGrokMediaFusionToolName(name: string): boolean {
|
||||
const normalized = normalizeFusionToolName(name);
|
||||
return normalized === BUILTIN_FUSION_GROK_MEDIA_TOOL_NAME || (GROK_MEDIA_FUSION_TOOL_NAMES as readonly string[]).includes(normalized);
|
||||
}
|
||||
|
||||
export function isFusionImageGenerationToolName(name: string): boolean {
|
||||
const normalized = normalizeFusionToolName(name);
|
||||
return normalized === BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME ||
|
||||
normalized === GROK_MEDIA_IMAGE_GENERATE_TOOL_NAME ||
|
||||
normalized === GROK_MEDIA_IMAGE_EDIT_TOOL_NAME ||
|
||||
normalized.startsWith(`${MEDIA_IMAGE_GENERATE_TOOL_PREFIX}_`) ||
|
||||
normalized.startsWith(`${MEDIA_IMAGE_EDIT_TOOL_PREFIX}_`);
|
||||
}
|
||||
|
||||
export function isFusionVideoGenerationToolName(name: string): boolean {
|
||||
const normalized = normalizeFusionToolName(name);
|
||||
return normalized === BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME ||
|
||||
normalized === GROK_MEDIA_VIDEO_START_TOOL_NAME ||
|
||||
normalized === GROK_MEDIA_JOB_GET_TOOL_NAME ||
|
||||
normalized === GROK_MEDIA_JOB_CANCEL_TOOL_NAME ||
|
||||
normalized.startsWith(`${MEDIA_VIDEO_START_TOOL_PREFIX}_`) ||
|
||||
normalized.startsWith(`${MEDIA_JOB_GET_TOOL_PREFIX}_`) ||
|
||||
normalized.startsWith(`${MEDIA_JOB_CANCEL_TOOL_PREFIX}_`);
|
||||
}
|
||||
|
||||
export function virtualModelProfilesUseMediaTools(profiles: VirtualModelProfileConfig[]): boolean {
|
||||
return profiles.some((profile) => profile.enabled !== false && profile.tools?.some((tool) =>
|
||||
isFusionImageGenerationToolName(tool.name) || isFusionVideoGenerationToolName(tool.name) || isGrokMediaFusionToolName(tool.name)
|
||||
));
|
||||
}
|
||||
|
||||
export function isFusionVisionToolName(name: string): boolean {
|
||||
@@ -1010,18 +1123,31 @@ export function selectedFusionToolNameFromProfile(toolDrafts: VirtualModelToolDr
|
||||
export function selectedFusionToolNamesFromProfile(toolDrafts: VirtualModelToolDraft[], profile: VirtualModelProfileConfig): string[] {
|
||||
const visionConfig = fusionVisionConfigFromProfile(profile);
|
||||
const webSearchConfig = fusionWebSearchConfigFromProfile(profile);
|
||||
const mediaConfig = fusionMediaConfigFromProfile(profile);
|
||||
const directTools = uniqueStrings(
|
||||
toolDrafts
|
||||
.map((tool) => normalizeFusionToolName(tool.name))
|
||||
.filter(isFusionToolName)
|
||||
.map((toolName) => {
|
||||
.flatMap((toolName) => {
|
||||
if (visionConfig?.toolName && toolName === visionConfig.toolName) {
|
||||
return BUILTIN_FUSION_VISION_TOOL_NAME;
|
||||
return [BUILTIN_FUSION_VISION_TOOL_NAME];
|
||||
}
|
||||
if (webSearchConfig?.toolName && toolName === webSearchConfig.toolName) {
|
||||
return BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME;
|
||||
return [BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME];
|
||||
}
|
||||
return toolName;
|
||||
if (toolName === BUILTIN_FUSION_GROK_MEDIA_TOOL_NAME) {
|
||||
return [BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME, BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME];
|
||||
}
|
||||
if (toolName === GROK_MEDIA_CAPABILITIES_TOOL_NAME) {
|
||||
return [];
|
||||
}
|
||||
if ((mediaConfig?.imageGenerateToolName === toolName || mediaConfig?.imageEditToolName === toolName) || isFusionImageGenerationToolName(toolName)) {
|
||||
return [BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME];
|
||||
}
|
||||
if ([mediaConfig?.videoStartToolName, mediaConfig?.jobGetToolName, mediaConfig?.jobCancelToolName].includes(toolName) || isFusionVideoGenerationToolName(toolName)) {
|
||||
return [BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME];
|
||||
}
|
||||
return [toolName];
|
||||
})
|
||||
);
|
||||
if (directTools.length > 0) {
|
||||
@@ -1063,12 +1189,20 @@ export function fusionToolDescription(name: string): string {
|
||||
|
||||
export function fusionToolDisplayName(name: string): string {
|
||||
const normalized = fusionToolBaseName(name);
|
||||
if (normalized === BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME) return "Image generation";
|
||||
if (normalized === BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME) return "Video generation";
|
||||
const option = fusionToolOptions.find((item) => item.value === normalized);
|
||||
return option?.label ?? normalized;
|
||||
}
|
||||
|
||||
export function fusionToolBaseName(name: string): string {
|
||||
const normalized = normalizeFusionToolName(name);
|
||||
if (isFusionImageGenerationToolName(normalized)) {
|
||||
return BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME;
|
||||
}
|
||||
if (isFusionVideoGenerationToolName(normalized)) {
|
||||
return BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME;
|
||||
}
|
||||
if (isFusionVisionToolName(normalized)) {
|
||||
return BUILTIN_FUSION_VISION_TOOL_NAME;
|
||||
}
|
||||
@@ -1105,7 +1239,7 @@ export function virtualModelExecutionSummary(profile: VirtualModelProfileConfig)
|
||||
execution?.matchMultimodal ? "image" : "",
|
||||
execution?.matchWebSearch ? "web search" : ""
|
||||
].filter(Boolean);
|
||||
return `${execution?.mode || "tool_loop"} · ${execution?.maxTurns ?? 6}/${execution?.maxToolCalls ?? 8}${features.length ? ` · ${features.join(", ")}` : ""}`;
|
||||
return `${execution?.mode || "tool_loop"}${features.length ? ` · ${features.join(", ")}` : ""}`;
|
||||
}
|
||||
|
||||
export function createMcpServerDraft(servers: GatewayMcpServerConfig[] = []): McpServerDraft {
|
||||
|
||||
@@ -3,7 +3,10 @@ import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { GatewayStartupErrorBanner } from "@ccr/ui/pages/home/components/layout.tsx";
|
||||
import { MediaModelConfigurationPanel, VirtualModelsView } from "@ccr/ui/pages/home/components/virtual-models.tsx";
|
||||
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
|
||||
import { createVirtualModelDraft } from "@ccr/ui/pages/home/shared/virtual-models.ts";
|
||||
import { appConfigFixture } from "../fixtures/index.ts";
|
||||
|
||||
test("GatewayStartupErrorBanner renders startup failure details", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
@@ -24,3 +27,49 @@ test("GatewayStartupErrorBanner stays hidden without a failure message", () => {
|
||||
|
||||
assert.equal(html, "");
|
||||
});
|
||||
|
||||
test("Fusion page does not render a standalone Grok media panel", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppI18nContext.Provider value={appCopy.zh}>
|
||||
<VirtualModelsView
|
||||
addVirtualModel={() => undefined}
|
||||
editVirtualModel={() => undefined}
|
||||
profiles={[]}
|
||||
removeVirtualModel={() => undefined}
|
||||
setVirtualModelEnabled={() => undefined}
|
||||
/>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
|
||||
assert.doesNotMatch(html, /Grok 生图与生视频/);
|
||||
assert.doesNotMatch(html, /Fusion 内置工具/);
|
||||
});
|
||||
|
||||
test("media tool configuration renders a generic provider model selector without backend settings", () => {
|
||||
const config = appConfigFixture();
|
||||
config.Providers = [{
|
||||
apikey: "provider-key",
|
||||
baseUrl: "https://media.example/v1",
|
||||
capabilities: [{ baseUrl: "https://media.example/v1", type: "openai_image_generations" }],
|
||||
models: ["image-model"],
|
||||
name: "Media Provider"
|
||||
}];
|
||||
const draft = createVirtualModelDraft(config);
|
||||
const html = renderToStaticMarkup(
|
||||
<AppI18nContext.Provider value={appCopy.zh}>
|
||||
<MediaModelConfigurationPanel
|
||||
draft={draft}
|
||||
kind="image"
|
||||
modelOptions={[{ label: "Media Provider/Image Model", value: "Media Provider/image-model" }]}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
|
||||
assert.match(html, /图片模型/);
|
||||
assert.match(html, /Media Provider\/Image Model/);
|
||||
assert.match(html, /ai-gateway/);
|
||||
assert.match(html, /Grok Agent/);
|
||||
assert.doesNotMatch(html, /Grok CLI(内置)/);
|
||||
assert.doesNotMatch(html, /API Key|允许读取图片|产物保留|图片并发|视频并发|执行后端/);
|
||||
});
|
||||
|
||||
Vendored
+8
@@ -25,6 +25,14 @@ export function appConfigFixture(): AppConfig {
|
||||
agent: {
|
||||
mcpServers: []
|
||||
},
|
||||
mediaTools: {
|
||||
allowedInputRoots: [],
|
||||
artifactTtlHours: 24,
|
||||
enabled: false,
|
||||
jobTimeoutMs: 600000,
|
||||
maxImageConcurrency: 2,
|
||||
maxVideoConcurrency: 1
|
||||
},
|
||||
profile: {
|
||||
enabled: true,
|
||||
profiles: []
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createProviderInstallLinkFromDraft,
|
||||
localAgentProviderIconUrls,
|
||||
providerCapabilitiesForProtocols,
|
||||
providerCapabilitiesForSave,
|
||||
providerCapabilityBaseUrlForProtocol,
|
||||
providerDisplayIcon,
|
||||
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
|
||||
@@ -58,6 +59,63 @@ test("multi-endpoint presets probe only each endpoint's declared protocols", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("custom providers probe generic image and video generation protocols", () => {
|
||||
const draft = {
|
||||
...createProviderDraft([]),
|
||||
baseUrl: "https://gateway.example/v1"
|
||||
};
|
||||
|
||||
const candidates = providerProbeCandidates(draft);
|
||||
|
||||
assert.deepEqual(candidates[0].protocols.slice(-2), [
|
||||
"openai_image_generations",
|
||||
"openai_video_generations"
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider save drops capabilities from the previous base URL", () => {
|
||||
const current = [{
|
||||
baseUrl: "https://new.example/v1",
|
||||
source: "detected" as const,
|
||||
type: "openai_chat_completions" as const
|
||||
}];
|
||||
const previous = [
|
||||
{
|
||||
baseUrl: "https://old.example/v1",
|
||||
source: "detected" as const,
|
||||
type: "openai_chat_completions" as const
|
||||
},
|
||||
{
|
||||
baseUrl: "https://old-media.example/v1",
|
||||
source: "detected" as const,
|
||||
type: "openai_image_generations" as const
|
||||
}
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
providerCapabilitiesForSave(current, previous, "https://old.example/v1", "https://new.example/v1"),
|
||||
current
|
||||
);
|
||||
});
|
||||
|
||||
test("provider save keeps explicit secondary media origins when the base URL is unchanged", () => {
|
||||
const current = [{
|
||||
baseUrl: "https://chat.example/v1",
|
||||
source: "detected" as const,
|
||||
type: "openai_chat_completions" as const
|
||||
}];
|
||||
const media = [{
|
||||
baseUrl: "https://media.example/v1",
|
||||
source: "preset" as const,
|
||||
type: "openai_image_generations" as const
|
||||
}];
|
||||
|
||||
assert.deepEqual(
|
||||
providerCapabilitiesForSave(current, media, "https://chat.example/v1/", "https://chat.example/v1"),
|
||||
[...current, ...media]
|
||||
);
|
||||
});
|
||||
|
||||
test("provider probe result drops unavailable selected protocols", () => {
|
||||
const draft = {
|
||||
...createProviderDraft([]),
|
||||
|
||||
@@ -3,9 +3,15 @@ import test from "node:test";
|
||||
import {
|
||||
createVirtualModelDraft,
|
||||
createVirtualModelDraftFromProfile,
|
||||
isBuiltInFusionToolName,
|
||||
selectedFusionToolNamesFromProfile,
|
||||
validateVirtualModelDraft,
|
||||
virtualModelProfileFromDraft
|
||||
virtualModelProfileFromDraft,
|
||||
virtualModelProfilesUseMediaTools,
|
||||
virtualModelToolSummary
|
||||
} from "@ccr/ui/pages/home/shared/virtual-models.ts";
|
||||
import { BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME, BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME } from "@ccr/core/contracts/app.ts";
|
||||
import { fusionToolOptions } from "@ccr/ui/pages/home/shared/options.ts";
|
||||
import { appConfigFixture } from "../fixtures/index.ts";
|
||||
|
||||
test("Fusion draft saves multiple selected tools into one profile", () => {
|
||||
@@ -32,7 +38,8 @@ test("Fusion draft saves multiple selected tools into one profile", () => {
|
||||
]);
|
||||
assert.equal(profile.execution.matchMultimodal, true);
|
||||
assert.equal(profile.execution.matchWebSearch, true);
|
||||
assert.equal(profile.execution.maxToolCalls, 8);
|
||||
assert.equal("maxToolCalls" in profile.execution, false);
|
||||
assert.equal("maxTurns" in profile.execution, false);
|
||||
assert.equal(profile.execution.clientToolsPolicy, "allow");
|
||||
assert.equal(profile.execution.streamMode, "optimistic");
|
||||
assert.equal(metadataString(profile.metadata, "fusionVision", "toolName"), "vision_understand_fusion_plus");
|
||||
@@ -57,6 +64,55 @@ test("Fusion default editing keeps client tools allowed", () => {
|
||||
assert.equal(savedProfile.execution.clientToolsPolicy, "allow");
|
||||
});
|
||||
|
||||
test("image and video generation are generic Fusion tools with independent model bindings", () => {
|
||||
const config = appConfigFixture();
|
||||
const draft = createVirtualModelDraft(config);
|
||||
draft.exactAliasesText = "fusion-media";
|
||||
draft.fixedModel = "provider/base-model";
|
||||
draft.imageGenerationModel = "Media Provider/grok-imagine-image-quality";
|
||||
draft.videoGenerationModel = "Media Provider/grok-imagine-video";
|
||||
draft.toolsText = `${BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME}, ${BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME}`;
|
||||
|
||||
assert.deepEqual(fusionToolOptions.slice(-2).map((option) => option.value), [BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME, BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME]);
|
||||
assert.equal(isBuiltInFusionToolName(BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME), true);
|
||||
assert.equal(isBuiltInFusionToolName(BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME), true);
|
||||
assert.equal(validateVirtualModelDraft(draft), "");
|
||||
|
||||
const profile = virtualModelProfileFromDraft(draft, [], undefined);
|
||||
assert.deepEqual(profile.tools.map((tool) => tool.name), [
|
||||
"image_generate_fusion_media",
|
||||
"image_edit_fusion_media",
|
||||
"video_generate_fusion_media",
|
||||
"media_job_get_fusion_media",
|
||||
"media_job_cancel_fusion_media"
|
||||
]);
|
||||
assert.equal(profile.execution.matchMultimodal, false);
|
||||
assert.equal(profile.execution.matchWebSearch, false);
|
||||
assert.equal(profile.metadata?.fusionTool, undefined);
|
||||
assert.equal(metadataString(profile.metadata, "fusionMedia", "imageModelSelector"), "Media Provider/grok-imagine-image-quality");
|
||||
assert.equal(metadataString(profile.metadata, "fusionMedia", "videoModelSelector"), "Media Provider/grok-imagine-video");
|
||||
const editDraft = createVirtualModelDraftFromProfile(profile, config);
|
||||
assert.equal(editDraft.toolsText, `${BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME}, ${BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME}`);
|
||||
assert.equal(editDraft.imageGenerationModel, "Media Provider/grok-imagine-image-quality");
|
||||
assert.equal(editDraft.videoGenerationModel, "Media Provider/grok-imagine-video");
|
||||
assert.equal(virtualModelProfilesUseMediaTools([profile]), true);
|
||||
assert.equal(virtualModelToolSummary(profile), "Image generation (Media Provider/grok-imagine-image-quality), Video generation (Media Provider/grok-imagine-video)");
|
||||
assert.deepEqual(selectedFusionToolNamesFromProfile(editDraft.tools, profile), [BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME, BUILTIN_FUSION_VIDEO_GENERATION_TOOL_NAME]);
|
||||
});
|
||||
|
||||
test("an imported Grok Agent supplies default API media models", () => {
|
||||
const config = appConfigFixture();
|
||||
config.Providers = [{
|
||||
apiKey: "ccr-local-agent-login",
|
||||
baseUrl: "https://cli-chat-proxy.grok.com/v1",
|
||||
models: ["grok-4.5"],
|
||||
name: "Imported Grok"
|
||||
}];
|
||||
const draft = createVirtualModelDraft(config);
|
||||
assert.equal(draft.imageGenerationModel, "Imported Grok/grok-imagine-image-quality");
|
||||
assert.equal(draft.videoGenerationModel, "Imported Grok/grok-imagine-video");
|
||||
});
|
||||
|
||||
function metadataString(metadata: Record<string, unknown> | undefined, key: string, field: string): string | undefined {
|
||||
const value = metadata?.[key];
|
||||
if (!value || typeof value !== "object") {
|
||||
|
||||
Reference in New Issue
Block a user