From 28dd4707f2ba0f6df0675c0203ab29382bd50592 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 15:59:44 -0400 Subject: [PATCH 01/20] feat(sse): add native Rosebud asset provider and MCP tools --- .env.example | 9 + .../features/0000-rosebud-native-provider.md | 1 + docs/providers/ROSEBUD.md | 159 ++++++++++ docs/reference/ENVIRONMENT.md | 11 + open-sse/config/imageRegistryData.ts | 11 + open-sse/config/providers/index.ts | 2 + .../providers/registry/rosebud/index.ts | 11 + open-sse/handlers/imageGeneration.ts | 4 + .../imageGeneration/providers/rosebud.ts | 101 ++++++ open-sse/mcp-server/httpAuthContext.ts | 7 + open-sse/mcp-server/server.ts | 31 ++ open-sse/mcp-server/tools/rosebudTools.ts | 129 ++++++++ open-sse/services/rosebud/artifact.ts | 31 ++ open-sse/services/rosebud/catalog.ts | 98 ++++++ open-sse/services/rosebud/cli.ts | 172 ++++++++++ open-sse/services/rosebud/publishing.ts | 90 ++++++ open-sse/services/rosebud/runtime.ts | 298 ++++++++++++++++++ open-sse/services/usage.ts | 3 + open-sse/services/usage/fetcherProviders.ts | 1 + open-sse/services/usage/rosebud.ts | 17 + open-sse/services/usage/supportedProviders.ts | 1 + .../[id]/ProviderDetailPageClient.tsx | 4 + .../[id]/components/RosebudPanel.tsx | 85 +++++ src/app/api/providers/[id]/models/route.ts | 6 + .../api/providers/[id]/sync-models/route.ts | 15 + src/app/api/providers/[id]/test/route.ts | 20 ++ src/app/api/rosebud/[action]/route.ts | 120 +++++++ src/app/api/v1/images/generations/route.ts | 10 +- src/lib/db/rosebudCredits.ts | 29 ++ src/lib/providerModels/modelEndpointGuard.ts | 5 + src/lib/rosebud/provider.ts | 119 +++++++ src/server/authz/routeGuard.ts | 2 + src/shared/constants/providers/noauth.ts | 14 + src/sse/services/imageCredentialRetry.ts | 6 + tests/integration/rosebud-live.test.mjs | 53 ++++ tests/unit/rosebud-cli.test.ts | 66 ++++ tests/unit/rosebud-image.test.ts | 80 +++++ tests/unit/rosebud-mcp.test.ts | 32 ++ tests/unit/rosebud-publishing.test.ts | 35 ++ tests/unit/rosebud-routing.test.ts | 21 ++ tests/unit/rosebud-runtime.test.ts | 166 ++++++++++ 41 files changed, 2073 insertions(+), 2 deletions(-) create mode 100644 changelog.d/features/0000-rosebud-native-provider.md create mode 100644 docs/providers/ROSEBUD.md create mode 100644 open-sse/config/providers/registry/rosebud/index.ts create mode 100644 open-sse/handlers/imageGeneration/providers/rosebud.ts create mode 100644 open-sse/mcp-server/tools/rosebudTools.ts create mode 100644 open-sse/services/rosebud/artifact.ts create mode 100644 open-sse/services/rosebud/catalog.ts create mode 100644 open-sse/services/rosebud/cli.ts create mode 100644 open-sse/services/rosebud/publishing.ts create mode 100644 open-sse/services/rosebud/runtime.ts create mode 100644 open-sse/services/usage/rosebud.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/RosebudPanel.tsx create mode 100644 src/app/api/rosebud/[action]/route.ts create mode 100644 src/lib/db/rosebudCredits.ts create mode 100644 src/lib/rosebud/provider.ts create mode 100644 tests/integration/rosebud-live.test.mjs create mode 100644 tests/unit/rosebud-cli.test.ts create mode 100644 tests/unit/rosebud-image.test.ts create mode 100644 tests/unit/rosebud-mcp.test.ts create mode 100644 tests/unit/rosebud-publishing.test.ts create mode 100644 tests/unit/rosebud-routing.test.ts create mode 100644 tests/unit/rosebud-runtime.test.ts diff --git a/.env.example b/.env.example index 41d3219e41ec..982bf42df43e 100644 --- a/.env.example +++ b/.env.example @@ -4072,3 +4072,12 @@ QUOTA_STORE_DRIVER=sqlite # warning when the clone is absent (public CI / forks). KEA-7886. # Used by: scripts/check/check-ui-kit-snapshot.mjs. # UI_KIT_CLONE=_references/cheaper-inference-ui-kit + +# Rosebud official CLI asset provider (credentials live in private account profiles) +ROSEBUD_CLI_BIN=rosebud +# Optional absolute path to @rosebudai/cli/dist/cli.js +ROSEBUD_CLI_SCRIPT= +# Empty uses DATA_DIR/rosebud +ROSEBUD_DATA_DIR= +# Process concurrency, not a credit cap +ROSEBUD_CONCURRENCY=2 diff --git a/changelog.d/features/0000-rosebud-native-provider.md b/changelog.d/features/0000-rosebud-native-provider.md new file mode 100644 index 000000000000..6ae69e961a46 --- /dev/null +++ b/changelog.d/features/0000-rosebud-native-provider.md @@ -0,0 +1 @@ +- **feat(sse):** Add a native Rosebud AI provider with dynamic asset models, image generation, specialized MCP tools, isolated CLI authorization and credit accounting. diff --git a/docs/providers/ROSEBUD.md b/docs/providers/ROSEBUD.md new file mode 100644 index 000000000000..2eca3c183f4e --- /dev/null +++ b/docs/providers/ROSEBUD.md @@ -0,0 +1,159 @@ +--- +title: Rosebud AI +description: Native Rosebud asset generation, credits, authorization and MCP tools. +--- + +# Rosebud AI + +Rosebud is an asset provider, not a chat model. OmniRoute uses the official +`@rosebudai/cli` and the connected account's existing Rosebud credits. It never +converts those credits into tokens or an invented USD price, buys credits, or +falls back to another provider. There is no default spending cap. `maxCredits` +is an optional caller-selected safeguard. + +## Installation and authorization + +Install Node.js 22 or newer and the official CLI on the **OmniRoute server**: + +```sh +npm install --global @rosebudai/cli@0.2.0 +``` + +Set `ROSEBUD_CLI_BIN` to the executable, or `ROSEBUD_CLI_SCRIPT` to the absolute +path of the package's `dist/cli.js` (executed with OmniRoute's Node runtime). +The executable cannot be selected by an HTTP request. + +Open **Providers → Rosebud AI → Connect Rosebud account**. Follow the private +approval link, then click **Check authorization and credits**. Refresh models to +populate the image catalog. Each connection has a separate server-side CLI +credential directory; approving the same logged-in browser account twice does +not provide another subscription. Check the returned Rosebud display name. + +For containers, persist `ROSEBUD_DATA_DIR` (default `$DATA_DIR/rosebud`) on a private +volume owned by the server UID. Credentials are in `accounts/` beneath +that root and must not be sent to clients, committed, or included in images. +The CLI enforces private file permissions. Mount the CLI package read-only and +the data volume read/write. A login on a laptop is not a login in the container. + +`ROSEBUD_CONCURRENCY` controls local CLI concurrency (default 2). This is a process +resource limit, not a credit allowance. Model schemas, current credit prices and +licenses come from `rosebud assets models`, not a baked-in model list. + +## Native images + +```http +POST /v1/images/generations +Authorization: Bearer +Idempotency-Key: city-v1 +Content-Type: application/json + +{ + "model": "rosebud/flux-2-flash", + "prompt": "A futuristic city at sunset", + "response_format": "b64_json", + "rosebud_inputs": {"asset_type": "background", "output_format": "png"} +} +``` + +The request must originate from trusted loopback/LAN, just like OmniRoute's +other CLI-backed generation handlers. Normal API-key model and connection +policies still apply. The handler validates the live model's inputs, quotes, +starts a durable job, waits, validates the output's format and returns Base64. +`n` must be 1. Unknown parameters (including unsupported size options) are +rejected instead of silently ignored. SVG and image transformations without a +text prompt use the specialized asset API. No public file hosting is created. + +The response includes `rosebud_usage` with the job identifier and native credit +accounting. Unknown actual charges/refunds remain absent; USD cost is `null`. + +## Specialized assets and MCP + +The local management API is `POST /api/rosebud/`. It requires management +authorization; these process-spawning endpoints are classified local-only. +Actions: `connect`, `authorize`, `account`, `models`, `quote`, `generate`, `status`, +`recover`, `list`, `download`, `publish`, `update`, `game_status`, `pull`. + +```json +{ + "connectionId": "", + "request": { + "model": "rosebud/rosebud-skybox", + "category": "skybox", + "input": { "prompt": "A peaceful mountain valley", "mode": "surface" }, + "idempotencyKey": "valley-sky-v1" + } +} +``` + +`generate` returns a job ID, usually with HTTP 202. Submit +`{"connectionId":"...","jobId":"..."}` to `status`, then `download` when +succeeded. Download returns validated Base64, MIME type and SHA-256, never a +server filesystem path. Job IDs are bound to the account profile. A started request never rotates to a different Rosebud account. Keep job IDs +and idempotency keys to resume after a timeout. A different payload using the +same idempotency key returns 409. + +MCP tools (management scope required): `rosebud_models`, `rosebud_account`, +`rosebud_quote`, `rosebud_generate_image`, `rosebud_generate_sprite`, +`rosebud_generate_skybox`, `rosebud_generate_3d_model`, `rosebud_generate_sound`, +`rosebud_generate_music`, `rosebud_generate_video`, `rosebud_generate_speech`, +`rosebud_asset_status`, `rosebud_recover_asset`, `rosebud_list_assets`, +`rosebud_download_asset`, `rosebud_publish_game`, `rosebud_update_game`, +`rosebud_game_status`, `rosebud_pull`. + +Inspect the live catalog first: categories can require inputs such as skybox +`mode`, a source image or a source 3D model. Sprite generation uses an image +model with its supported `asset_type` input; it does not promise animation. +These tools use OmniRoute's existing authenticated MCP HTTP client and scope +checks. They do not start a second router. There is no dedicated A2A skill yet; +A2A clients may use an authorized MCP-capable agent or these HTTP endpoints. + +## Publishing + +Publishing and asset generation are separate operations. Publishing accepts an +explicit, stable `game.gameId` UUID and a map of relative filenames to UTF-8 +contents in `game.files`, plus optional title/description. Use the same ID for +updates/status/pull; updates preserve the CLI's conflict/version checks. Do not +retry an uncertain first publish with a new ID. The CLI's saved project handle +is authoritative. Binary game-bundle uploads are not exposed by this adapter. +The Rosebud editor and Rosie chat are not exposed as chat endpoints. + +## Recovery and accounting + +Rosebud's CLI stores the upstream idempotency key before sending a paid request. +OmniRoute additionally persists its own job and account binding. Recovery can +re-run a submission only when the CLI's matching durable pending record still +exists. A lost acknowledgement without that record requires reconciliation; +OmniRoute will not guess and pay twice. Cancellation stops waiting, not a remote +Rosebud job. The official CLI does not offer remote job cancellation. + +Insufficient credit is returned as HTTP 402, authentication failures as 401, +throttling as 429. The CLI handles its documented bounded throttling waits. +Credit events are upserted by job ID, so repeated status reads do not double +count. The account panel exposes the live balance and native-credit history. +Completed artifacts and job records are retained in the private data volume; +operators must include it in their storage/backup policy. No cleanup removes +uncertain or in-flight jobs. + +## Validation + +```sh +node --import tsx/esm --test --test-force-exit tests/unit/rosebud-*.test.ts +``` + +Fixtures cover schema rejection, prompt argument safety, account isolation, +idempotency conflicts, uncertain outcomes, credit exhaustion, native image +dispatch and private artifact responses. Live verification requires an approved +Rosebud account. A successful CLI-only invocation is not endpoint acceptance. + +Official references: [asset instructions](https://rosebud.ai/developer-assets/AGENTS.md), +[CLI](https://github.com/rosebudai/rosebud-cli), and +[publishing documentation](https://rosebud.ai/developers/agent-publishing/docs). + +Live image verification (spends existing account credits): set `ROSEBUD_LIVE_TEST=1`, +`OMNIROUTE_BASE_URL`, `OMNIROUTE_API_KEY` (management scope), and +`ROSEBUD_CONNECTION_ID`, then run +`node --test tests/integration/rosebud-live.test.mjs`. +Use an account without concurrent external generation when comparing balances. +The private job directory is owned by one OmniRoute server process; sharing it +between independently running servers is unsupported. A missing or malformed +crashed-worker lease requires operator reconciliation rather than guessed retries. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7e9349de60f4..ddbd4f76960f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1961,3 +1961,14 @@ Used by `open-sse/services/combo.ts` and `src/lib/quota/quotaScheduler.ts` for p | `OMNIROUTE_QUOTA_AWARE_ROUTING` | `0` | `open-sse/services/combo.ts` | When `1`, skip connections whose per-window token budget (`rateLimitOverrides.tpm`, table `provider_quota_state`) cannot afford the estimated request cost before dispatch. Fail-open when no budget configured. | | `OMNIROUTE_ESTIMATOR_CALIBRATION` | _(on)_ | `open-sse/services/estimatorCalibration.ts` | Feedback calibration of the context-guard token estimator (#14931): per (provider, model, tools present) EMA of `usage.prompt_tokens` / estimate, clamped to 0.15–2.0, in memory only. `off`, `0` or `false` disables recording and application. | | `GROK_SUBSCRIPTION_IMAGES_ENABLED` | `false` | `open-sse/config/imageRegistry.ts` | Registers the `xai-oauth` (`xao`) and `grok-cli` image routes. Dashboard feature flag of the same name; a DB override wins over this value. | + +## Rosebud asset generation + +| Variable | Default | Purpose | +| --- | --- | --- | +| `ROSEBUD_CLI_BIN` | `rosebud` | Official CLI executable installed on the server. | +| `ROSEBUD_CLI_SCRIPT` | empty | Absolute path to its JavaScript entrypoint, run by the server Node runtime. | +| `ROSEBUD_DATA_DIR` | `$DATA_DIR/rosebud` | Private persistent account profiles, jobs and generated artifacts. | +| `ROSEBUD_CONCURRENCY` | `2` | Concurrent CLI processes, not a credit limit. | + +See [Rosebud](../providers/ROSEBUD.md). diff --git a/open-sse/config/imageRegistryData.ts b/open-sse/config/imageRegistryData.ts index 8c2ca440ce77..33ed44e4cb7b 100644 --- a/open-sse/config/imageRegistryData.ts +++ b/open-sse/config/imageRegistryData.ts @@ -182,6 +182,17 @@ export const IMAGE_MODEL_ALIASES: Record = { }; export const IMAGE_PROVIDERS: Record = { + rosebud: { + id: "rosebud", + alias: "rosebud", + baseUrl: "rosebud://assets", + authType: "cli", + authHeader: "none", + format: "rosebud-assets", + models: [], + supportedSizes: [], + }, + zenmux: ZENMUX_IMAGE_PROVIDER, "muse-code": MUSE_CODE_IMAGE_PROVIDER, agnes: { diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index e7f38d29ba58..4d754f679b0c 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -1,3 +1,4 @@ +import { rosebudProvider } from "./registry/rosebud/index.ts"; import type { RegistryEntry } from "./shared.ts"; import { unorouterProvider } from "./registry/unorouter/index.ts"; @@ -480,6 +481,7 @@ export const REGISTRY: Record = { "devin-cli": devin_cliProvider, "devin-cli-agentic": devin_cli_agenticProvider, auggie: auggieProvider, + rosebud: rosebudProvider, chutes: chutesProvider, chenzk: chenzkProvider, factory: factoryProvider, diff --git a/open-sse/config/providers/registry/rosebud/index.ts b/open-sse/config/providers/registry/rosebud/index.ts new file mode 100644 index 000000000000..6a85fb8e92c4 --- /dev/null +++ b/open-sse/config/providers/registry/rosebud/index.ts @@ -0,0 +1,11 @@ +import type { RegistryEntry } from "../../shared.ts"; +export const rosebudProvider: RegistryEntry = { + id: "rosebud", + alias: "rosebud", + format: "rosebud-assets", + executor: "rosebud-assets", + baseUrl: "rosebud://assets", + authType: "none", + authHeader: "none", + models: [], +}; diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 88b35ea0b7d9..9e029faa9e95 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -1,3 +1,4 @@ +import { handleRosebudImageGeneration } from "./imageGeneration/providers/rosebud.ts"; import { randomUUID } from "crypto"; /** Image generation handler for POST /v1/images/generations (OpenAI-compatible). */ @@ -709,6 +710,9 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "rosebud-assets") { + return handleRosebudImageGeneration({ body, credentials, peerLocality, signal }); + } if (providerConfig.format === "cursor-agent-image") { return handleCursorAgentImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/rosebud.ts b/open-sse/handlers/imageGeneration/providers/rosebud.ts new file mode 100644 index 000000000000..7acef4c8d1af --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/rosebud.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { rosebudConnection, recordRosebudJob, generateRosebud } from "@/lib/rosebud/provider"; +import { rosebudRuntime } from "../../../services/rosebud/runtime.ts"; +import { RosebudError } from "../../../services/rosebud/cli.ts"; + +const imageRequest = z + .object({ + model: z.string(), + prompt: z.string().optional(), + n: z.literal(1).optional(), + response_format: z.literal("b64_json").optional(), + rosebud_inputs: z.record(z.string(), z.unknown()).optional(), + idempotency_key: z.string().min(1).max(200).optional(), + }) + .strict(); + +export async function handleRosebudImageGeneration(options: { + body: unknown; + credentials?: { connectionId?: string } | null; + peerLocality?: string | null; + signal?: AbortSignal | null; +}) { + if (!["loopback", "lan"].includes(options.peerLocality || "")) { + return { + success: false as const, + status: 403, + error: "Rosebud CLI requires a trusted local or LAN caller", + }; + } + try { + const body = imageRequest.parse(options.body); + const id = options.credentials?.connectionId; + if (!id) throw new RosebudError("connection_required", 401); + const { connection, profile } = await rosebudConnection(id); + if (!connection.isActive) throw new RosebudError("account_not_active", 401); + const input: Record = { + ...body.rosebud_inputs, + ...(body.prompt !== undefined ? { prompt: body.prompt } : {}), + }; + const catalog = await rosebudRuntime.models(profile); + const selected = catalog.find((model) => model.id === body.model.replace(/^rosebud\//, "")); + if (!selected || selected.category !== "image") + throw new RosebudError("not_an_image_model", 400); + if ( + !selected.output_formats.some((format) => ["png", "jpg", "jpeg", "webp"].includes(format)) || + (typeof input.output_format === "string" && + !["png", "jpg", "jpeg", "webp"].includes(input.output_format)) + ) { + throw new RosebudError("use_asset_tool_for_non_raster_output", 400); + } + let job = await generateRosebud(id, profile, { + model: body.model, + input, + idempotencyKey: body.idempotency_key, + }); + recordRosebudJob(id, job); + const deadline = Date.now() + 600000; + while (!["succeeded", "failed", "cancelled"].includes(job.status)) { + if (options.signal?.aborted || Date.now() > deadline) { + return { + success: false as const, + status: 504, + error: `Rosebud job ${job.id} continues; retrieve it with rosebud_asset_status`, + }; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + job = await rosebudRuntime.refresh(profile, job.id); + recordRosebudJob(id, job); + } + if (job.status !== "succeeded") throw new RosebudError("generation_failed", 502); + const artifact = await rosebudRuntime.artifact(profile, job.id); + const data = artifact.data; + const raster = + data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || + data.subarray(0, 3).equals(Buffer.from([255, 216, 255])) || + (data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WEBP"); + if (!raster) throw new RosebudError("use_asset_tool_for_non_raster_output", 400); + return { + success: true as const, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: data.toString("base64") }], + rosebud_usage: { + job_id: job.id, + unit: "rosebud_credits", + estimated: job.estimatedCredits, + consumed: job.actualCredits, + refunded: job.refundedCredits, + usd_cost: null, + }, + }, + }; + } catch (error) { + return { + success: false as const, + status: + error instanceof z.ZodError ? 400 : error instanceof RosebudError ? error.status : 502, + error: error instanceof RosebudError ? error.code : "Rosebud image request failed", + }; + } +} diff --git a/open-sse/mcp-server/httpAuthContext.ts b/open-sse/mcp-server/httpAuthContext.ts index e8e156ad05cc..268b7ef93f8c 100644 --- a/open-sse/mcp-server/httpAuthContext.ts +++ b/open-sse/mcp-server/httpAuthContext.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; type McpHttpAuthContext = { + peerLocality?: string; authorization?: string; cookie?: string; xApiKey?: string; @@ -48,6 +49,10 @@ export function getMcpHttpAuthHeadersForInternalFetch(): Record * which scope it is in — that is the only way to make the env key a *stdio-only* * fallback instead of a silent substitute for a missing HTTP caller identity. */ +export function getMcpHttpPeerLocality(): string | undefined { + return mcpHttpAuthContext.getStore()?.peerLocality; +} + export function hasMcpHttpAuthContext(): boolean { return mcpHttpAuthContext.getStore() !== undefined; } @@ -98,8 +103,10 @@ export async function withMcpHttpAuthContext( request: Request, callback: () => Promise ): Promise { + const { getRequestPeerLocality } = await import("../../src/shared/utils/apiAuth.ts"); return mcpHttpAuthContext.run( { + peerLocality: getRequestPeerLocality(request), authorization: headerValue(request, "authorization"), cookie: headerValue(request, "cookie"), xApiKey: headerValue(request, "x-api-key"), diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 9d45755a7048..384bd4ee5f32 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -1,3 +1,4 @@ +import { rosebudTools } from "./tools/rosebudTools.ts"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { APP_CONFIG } from "@/shared/constants/appConfig"; @@ -132,6 +133,7 @@ const TOTAL_MCP_TOOL_COUNT = countUniqueMcpTools({ pluginTools, notionTools, obsidianTools, + rosebudTools, localCorpusTools, compressionTools, }); @@ -312,6 +314,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { ...pluginTools.map((t) => t.name), ...gamificationTools.map((t) => t.name), ...obsidianTools.map((t) => t.name), + ...rosebudTools.map((t) => t.name), ...notionTools.map((t) => t.name), ...localCorpusTools.map((t) => t.name), ]); @@ -946,6 +949,34 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { }); // ── Obsidian Context Source Tools ───────────── + rosebudTools.forEach((tool) => { + server.registerTool( + tool.name, + { description: tool.description, inputSchema: tool.inputSchema }, + withScopeEnforcement( + tool.name, + async (args) => { + try { + return { + content: [{ type: "text" as const, text: JSON.stringify(await tool.handler(args)) }], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text" as const, + text: toSafeMcpErrorMessage(error, "Rosebud operation failed"), + }, + ], + }; + } + }, + tool.scopes + ) + ); + }); + obsidianTools.forEach((toolDef) => { server.registerTool( toolDef.name, diff --git a/open-sse/mcp-server/tools/rosebudTools.ts b/open-sse/mcp-server/tools/rosebudTools.ts new file mode 100644 index 000000000000..95038424a5b3 --- /dev/null +++ b/open-sse/mcp-server/tools/rosebudTools.ts @@ -0,0 +1,129 @@ +import { hasMcpHttpAuthContext, getMcpHttpPeerLocality } from "../httpAuthContext.ts"; +import { z } from "zod"; +import { rosebudPublishInput } from "../../services/rosebud/publishing.ts"; +import { omniRouteFetch } from "../internalFetch.ts"; + +const account = z.object({ connectionId: z.string().uuid() }); +const request = account.extend({ + model: z.string(), + input: z.record(z.string(), z.unknown()), + idempotencyKey: z.string().min(1).max(200).optional(), + maxCredits: z.number().int().nonnegative().optional(), +}); +const job = account.extend({ jobId: z.string().regex(/^[a-f0-9]{64}$/) }); +function tool(name: string, action: string, description: string, schema: z.ZodType) { + return { + name, + description, + scopes: ["manage"], + inputSchema: schema, + handler: async (raw: unknown) => { + if ( + hasMcpHttpAuthContext() && + !["loopback", "lan"].includes(getMcpHttpPeerLocality() || "") + ) { + throw new Error("Rosebud CLI requires a trusted local or LAN caller"); + } + const args = schema.parse(raw) as Record; + const { connectionId, jobId, assetId, game, ...generation } = args; + return omniRouteFetch("/api/rosebud/" + action, { + method: "POST", + body: JSON.stringify({ + connectionId, + ...(jobId ? { jobId } : {}), + ...(assetId ? { assetId } : {}), + ...(game ? { game } : {}), + ...(["generate", "quote"].includes(action) ? { request: generation } : {}), + }), + signal: AbortSignal.timeout(180000), + }); + }, + }; +} +export const rosebudTools = [ + tool( + "rosebud_models", + "models", + "Discover official Rosebud models, input schemas, native credit prices and licenses.", + account + ), + tool( + "rosebud_account", + "account", + "Read Rosebud authorization, available credits and native-credit generation history.", + account + ), + tool( + "rosebud_quote", + "quote", + "Quote a Rosebud asset in native credits without generating it.", + request + ), + ...["image", "sprite", "skybox", "3d_model", "sound", "music", "video", "speech"].map( + (category) => + tool( + "rosebud_generate_" + category, + "generate", + "Generate a Rosebud " + + category + + ". Choose a matching model and its exact input schema; use a stable idempotencyKey for retries.", + request.extend({ + category: z + .literal( + category === "sprite" + ? "image" + : category === "3d_model" + ? "model_3d" + : category === "sound" + ? "sound_effect" + : category + ) + .default( + category === "sprite" + ? "image" + : category === "3d_model" + ? "model_3d" + : category === "sound" + ? "sound_effect" + : category + ), + }) + ) + ), + ...["publish", "update", "game_status", "pull"].map((action) => + tool( + "rosebud_" + + (action === "publish" ? "publish_game" : action === "update" ? "update_game" : action), + action, + "Explicit Rosebud game " + + action + + "; separate from asset generation. Preserve the returned gameId for subsequent operations.", + account.extend({ game: rosebudPublishInput }) + ) + ), + tool( + "rosebud_recover_asset", + "recover", + "Resume the identical uncertain generation without creating a new paid job.", + job + ), + tool( + "rosebud_asset_status", + "status", + "Recover an existing Rosebud generation job without creating another paid job.", + job + ), + tool("rosebud_list_assets", "list", "List this Rosebud account's generated assets.", account), + tool( + "rosebud_download_asset", + "download", + "Retrieve a completed OmniRoute Rosebud job artifact as Base64.", + account.extend({ + jobId: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), + assetId: z.string().uuid().optional(), + }) + ), +]; diff --git a/open-sse/services/rosebud/artifact.ts b/open-sse/services/rosebud/artifact.ts new file mode 100644 index 000000000000..41a4140249de --- /dev/null +++ b/open-sse/services/rosebud/artifact.ts @@ -0,0 +1,31 @@ +import { RosebudError } from "./cli.ts"; + +export function rosebudArtifactType(data: Buffer, category: string): string { + let mime = ""; + if (data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) + mime = "image/png"; + else if (data.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) mime = "image/jpeg"; + else if (data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WEBP") + mime = "image/webp"; + else if (data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WAVE") + mime = "audio/wav"; + else if (data.toString("ascii", 0, 3) === "ID3" || (data[0] === 255 && (data[1] & 224) === 224)) + mime = "audio/mpeg"; + else if (data.toString("ascii", 0, 4) === "OggS") mime = "audio/ogg"; + else if (data.toString("ascii", 0, 4) === "glTF") mime = "model/gltf-binary"; + else if (data.toString("ascii", 4, 8) === "ftyp") mime = "video/mp4"; + else if (/^(?:<\?xml[^>]*>\s*)?]/i.test(data.toString("utf8", 0, 256).trim())) + mime = "image/svg+xml"; + const allowed = + category === "any" + ? Boolean(mime) + : category === "model_3d" + ? mime.startsWith("model/") + : category === "video" + ? mime.startsWith("video/") + : ["music", "sound_effect", "speech"].includes(category) + ? mime.startsWith("audio/") + : mime.startsWith("image/"); + if (!mime || !allowed) throw new RosebudError("invalid_artifact_format", 502); + return mime; +} diff --git a/open-sse/services/rosebud/catalog.ts b/open-sse/services/rosebud/catalog.ts new file mode 100644 index 000000000000..859d781a7a76 --- /dev/null +++ b/open-sse/services/rosebud/catalog.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +export const rosebudCategory = z.enum([ + "image", + "skybox", + "model_3d", + "sound_effect", + "music", + "video", + "speech", +]); +export type RosebudCategory = z.infer; +export const rosebudModel = z + .object({ + id: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/), + name: z.string(), + category: rosebudCategory, + status: z.string().optional(), + inputs: z.array( + z + .object({ + name: z.string(), + type: z.string(), + required: z.boolean().optional(), + values: z.array(z.union([z.string(), z.number()])).optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + max_length: z.number().optional(), + }) + .passthrough() + ), + price: z.record(z.string(), z.unknown()).optional(), + license: z.record(z.string(), z.unknown()).optional(), + output_formats: z.array(z.string()).default([]), + }) + .passthrough(); +export type RosebudModel = z.infer; + +/** Validate against the live provider schema before quoting or spending credits. */ +export function validateRosebudInput(model: RosebudModel, input: Record) { + const fields: Record = {}; + for (const spec of model.inputs) { + let field: z.ZodType; + if (spec.type === "integer" || spec.type === "number") { + let number = z.number().finite(); + if (spec.type === "integer") number = number.int(); + if (spec.minimum !== undefined) number = number.min(spec.minimum); + if (spec.maximum !== undefined) number = number.max(spec.maximum); + field = number; + } else if (spec.type === "boolean") field = z.boolean(); + else if (spec.type === "enum") { + field = z + .union([z.string(), z.number()]) + .refine((value) => spec.values?.includes(value) === true); + } else if (spec.type === "string" || spec.type === "file") { + let string = z.string().min(1); + if (spec.max_length !== undefined) string = string.max(spec.max_length); + field = + spec.type === "file" + ? string.refine( + (value) => /^[a-zA-Z0-9_-]+$/.test(value) || /^https:\/\//.test(value), + "Use an asset ID or HTTPS URL" + ) + : string; + } else throw new Error("Unsupported Rosebud input type"); + fields[spec.name] = spec.required ? field : field.optional(); + } + return z.object(fields).strict().parse(input); +} + +export function rosebudEndpoint(category: RosebudCategory): string[] { + return category === "image" ? ["images"] : []; +} + +export function rosebudCatalogEntry(model: RosebudModel) { + return { + id: model.id, + name: model.name, + type: model.category === "image" ? "image" : model.category, + supportedEndpoints: rosebudEndpoint(model.category), + endpointCapabilitySource: "declared", + apiFormat: + model.category === "image" + ? "images-generations" + : model.category === "speech" + ? "audio-speech" + : model.category, + rosebud: { + category: model.category, + inputs: model.inputs, + price: model.price, + license: model.license, + outputFormats: model.output_formats, + creditUnit: "rosebud_credits", + usdCost: null, + }, + }; +} diff --git a/open-sse/services/rosebud/cli.ts b/open-sse/services/rosebud/cli.ts new file mode 100644 index 000000000000..71225222538e --- /dev/null +++ b/open-sse/services/rosebud/cli.ts @@ -0,0 +1,172 @@ +import { spawn } from "node:child_process"; +import { mkdir, realpath } from "node:fs/promises"; +import { resolve, join, isAbsolute, relative } from "node:path"; +import { homedir } from "node:os"; + +export type JsonObject = Record; +export class RosebudError extends Error { + jobId?: string; + constructor( + public code: string, + public status: number, + public uncertain = false + ) { + super(`Rosebud: ${code}`); + } +} +export function rosebudRoot() { + return resolve( + process.env.ROSEBUD_DATA_DIR || + join(process.env.DATA_DIR || join(homedir(), ".omniroute"), "rosebud") + ); +} +export async function rosebudProfile(profile: string) { + if (!/^[a-zA-Z0-9_-]{1,80}$/.test(profile)) throw new RosebudError("invalid_profile", 400); + const root = rosebudRoot(); + await mkdir(root, { recursive: true, mode: 0o700 }); + const folder = join(root, "accounts", profile); + await mkdir(folder, { recursive: true, mode: 0o700 }); + const actual = await realpath(folder); + const rel = relative(await realpath(root), actual); + if (rel.startsWith("..") || isAbsolute(rel)) throw new RosebudError("unsafe_profile", 400); + return actual; +} +export type RosebudRunner = ( + profile: string, + args: string[], + timeoutMs?: number +) => Promise; + +// The CLI briefly locks its credentials even for reads. Serialize commands for +// one profile while allowing different accounts to use the process pool. +const PROFILE_QUEUES = Symbol.for("omniroute.rosebud.profileQueues"); +type ProfileQueues = Map; count: number }>; +const processState = globalThis as typeof globalThis & { [PROFILE_QUEUES]?: ProfileQueues }; +const profileQueues = (processState[PROFILE_QUEUES] ??= new Map()); +async function acquireProfileSlot(profile: string) { + const previous = profileQueues.get(profile); + if ((previous?.count || 0) >= 128) throw new RosebudError("queue_full", 429); + let release!: () => void; + const tail = new Promise((resolve) => { + release = resolve; + }); + const entry = { tail, count: (previous?.count || 0) + 1 }; + profileQueues.set(profile, entry); + await previous?.tail; + return () => { + release(); + const latest = profileQueues.get(profile); + if (latest) latest.count--; + if (latest === entry) profileQueues.delete(profile); + }; +} + +let activeProcesses = 0; +const processWaiters: Array<() => void> = []; +async function acquireProcessSlot() { + const configured = Number(process.env.ROSEBUD_CONCURRENCY || 2); + const limit = Number.isInteger(configured) && configured > 0 && configured <= 32 ? configured : 2; + if (activeProcesses >= limit) { + if (processWaiters.length >= 128) throw new RosebudError("queue_full", 429); + await new Promise((resolve) => processWaiters.push(resolve)); + } else activeProcesses++; +} +function releaseProcessSlot() { + const next = processWaiters.shift(); + if (next) next(); + else activeProcesses--; +} + +/** No shell, inherited provider keys, user-selected executable or public stderr. */ +export const runRosebud: RosebudRunner = async (profile, args, timeoutMs = 120000) => { + const folder = await rosebudProfile(profile); + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + SYSTEMROOT: process.env.SYSTEMROOT, + TMPDIR: process.env.TMPDIR, + TEMP: process.env.TEMP, + ROSEBUD_CREDENTIALS_DIR: folder, + }; + const bin = process.env.ROSEBUD_CLI_SCRIPT + ? process.execPath + : process.env.ROSEBUD_CLI_BIN || "rosebud"; + const argv = process.env.ROSEBUD_CLI_SCRIPT ? [process.env.ROSEBUD_CLI_SCRIPT, ...args] : args; + const releaseProfile = await acquireProfileSlot(profile); + try { + await acquireProcessSlot(); + } catch (error) { + releaseProfile(); + throw error; + } + return new Promise((accept, reject) => { + const child = spawn(bin, argv, { + shell: false, + windowsHide: true, + env, + cwd: folder, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = "", + stderr = "", + exceeded = false; + let killTimer: ReturnType | undefined; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 5000); + }, timeoutMs); + const collect = (chunk: Buffer, error: boolean) => { + if (stdout.length + stderr.length + chunk.length > 8 * 1024 * 1024) { + exceeded = true; + child.kill("SIGTERM"); + return; + } + if (error) stderr += chunk.toString(); + else stdout += chunk.toString(); + }; + child.stdout.on("data", (chunk) => collect(chunk, false)); + child.stderr.on("data", (chunk) => collect(chunk, true)); + child.on("error", () => { + clearTimeout(timer); + reject(new RosebudError("cli_unavailable", 503)); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + let result: JsonObject; + try { + result = JSON.parse(code === 0 ? stdout : stderr); + } catch { + reject(new RosebudError(exceeded ? "output_too_large" : "invalid_cli_response", 502, true)); + return; + } + if (code === 0 && !signal) { + accept(result); + return; + } + const error = + result.error && typeof result.error === "object" ? (result.error as JsonObject) : result; + const name = + typeof error.code === "string" && /^[a-z_]{1,80}$/.test(error.code) + ? error.code + : "cli_failed"; + reject( + new RosebudError( + name, + name === "insufficient_credits" + ? 402 + : ["invalid_key", "key_expired", "key_revoked", "login_required"].includes(name) + ? 401 + : name === "throttled" + ? 429 + : 502, + code === 4 || Boolean(signal) + ) + ); + }); + }).finally(() => { + releaseProcessSlot(); + releaseProfile(); + }); +}; diff --git a/open-sse/services/rosebud/publishing.ts b/open-sse/services/rosebud/publishing.ts new file mode 100644 index 000000000000..fd523ab7374f --- /dev/null +++ b/open-sse/services/rosebud/publishing.ts @@ -0,0 +1,90 @@ +import { mkdir, writeFile, access, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { runRosebud, rosebudRoot, RosebudError, type RosebudRunner } from "./cli.ts"; + +export const rosebudPublishInput = z + .object({ + gameId: z.string().uuid().optional(), + title: z.string().min(1).max(255).optional(), + description: z.string().max(4000).optional(), + expectedVersion: z.string().min(1).max(100).optional(), + files: z.record(z.string(), z.string()).optional(), + }) + .strict(); +/** Publishing has a separate permission path and never receives arbitrary server paths. */ +export async function rosebudPublish( + profile: string, + action: "publish" | "update" | "game_status" | "pull", + raw: unknown, + run: RosebudRunner = runRosebud +) { + const input = rosebudPublishInput.parse(raw); + const gameId = input.gameId || ""; + if (!gameId) throw new RosebudError("game_id_required", 400); + const folder = join(rosebudRoot(), "games", profile, gameId); + const state = join(folder, "project.json"), + build = join(folder, "build"); + await mkdir(build, { recursive: true, mode: 0o700 }); + const lock = join(folder, "operation.lock"); + try { + await writeFile(lock, action, { flag: "wx", mode: 0o600 }); + } catch { + throw new RosebudError("game_operation_requires_reconciliation", 409); + } + let preserveLock = false; + try { + if (action === "publish") { + try { + await access(state); + throw new RosebudError("game_already_exists_use_update", 409); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (input.files) { + let bytes = 0; + for (const [path, content] of Object.entries(input.files)) { + if ( + !/^[a-zA-Z0-9_/-]+\.[a-zA-Z0-9]+$/.test(path) || + path.split("/").some((p) => p === ".." || p === ".") || + path.startsWith("/") + ) + throw new RosebudError("unsafe_game_path", 400); + bytes += Buffer.byteLength(content); + if (bytes > 10 * 1024 * 1024) + throw new RosebudError("game_text_exceeds_platform_limit", 413); + } + for (const [path, content] of Object.entries(input.files)) { + const file = join(build, path); + await mkdir(join(file, ".."), { recursive: true }); + await writeFile(file, content, { mode: 0o600 }); + } + } + const args = [action === "game_status" ? "status" : action, "--state", state]; + if (["publish", "update", "pull"].includes(action)) args.push("--directory", build); + if (input.title) args.push("--title", input.title); + if (input.description) args.push("--description", input.description); + if (input.expectedVersion && action === "update") + args.push("--expected-version", input.expectedVersion); + let result; + try { + result = await run(profile, args, 600000); + } catch (error) { + // Unknown publishes must never be retried into duplicate remote games. + preserveLock = error instanceof RosebudError && error.uncertain; + throw error; + } + // A normal active login publishes directly. Never expose private claim/approval URLs. + return { + gameId, + status: result.status, + version: result.version, + play_url: result.play_url, + claimed: result.claimed, + requiresAuthorization: Boolean(result.approval_url || result.claim_url), + }; + } finally { + if (!preserveLock) await rm(lock, { force: true }); + } +} diff --git a/open-sse/services/rosebud/runtime.ts b/open-sse/services/rosebud/runtime.ts new file mode 100644 index 000000000000..924270074158 --- /dev/null +++ b/open-sse/services/rosebud/runtime.ts @@ -0,0 +1,298 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, readdir, rename, writeFile, stat, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { rosebudArtifactType } from "./artifact.ts"; +import { rosebudModel, validateRosebudInput, type RosebudModel } from "./catalog.ts"; +import { + runRosebud, + rosebudRoot, + rosebudProfile, + RosebudError, + type RosebudRunner, + type JsonObject, +} from "./cli.ts"; + +export const generationInput = z + .object({ + model: z.string().regex(/^(?:rosebud\/)?[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/), + input: z.record(z.string(), z.unknown()), + category: z + .enum(["image", "skybox", "model_3d", "sound_effect", "music", "video", "speech"]) + .optional(), + idempotencyKey: z.string().min(1).max(200).optional(), + maxCredits: z.number().int().nonnegative().optional(), + }) + .strict(); +export type RosebudJob = { + id: string; + profile: string; + model: string; + category: string; + input: JsonObject; + fingerprint: string; + status: string; + upstreamId?: string; + assetId?: string; + estimatedCredits?: number; + actualCredits?: number; + refundedCredits?: number; + maxCredits?: number; + error?: string; + updatedAt: string; +}; +const processInstance = randomUUID(); +const ordered = (input: JsonObject) => + Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b))); +const hash = (text: string) => createHash("sha256").update(text).digest("hex"); +export class RosebudRuntime { + constructor(private run: RosebudRunner = runRosebud) {} + async models(profile: string): Promise { + const result = await this.run(profile, ["assets", "models"]); + return z + .array(rosebudModel) + .parse(result.models) + .filter((model) => !model.status || model.status === "live"); + } + async account(profile: string) { + return this.run(profile, ["whoami"]); + } + async quote(profile: string, modelId: string, input: JsonObject) { + const model = (await this.models(profile)).find( + (entry) => entry.id === modelId.replace(/^rosebud\//, "") + ); + if (!model) throw new RosebudError("unknown_model", 400); + const validated = validateRosebudInput(model, input); + const dir = join(rosebudRoot(), "quotes", randomUUID()); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const file = join(dir, "input.json"); + await writeFile(file, JSON.stringify(validated), { mode: 0o600 }); + try { + return await this.run(profile, [ + "assets", + "quote", + "--model", + model.id, + "--input-json", + file, + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + private folder(id: string) { + if (!/^[a-f0-9]{64}$/.test(id)) throw new RosebudError("invalid_job", 400); + return join(rosebudRoot(), "jobs", id); + } + private async save(job: RosebudJob) { + job.updatedAt = new Date().toISOString(); + const path = join(this.folder(job.id), "job.json"), + temporary = path + "." + randomUUID(); + await writeFile(temporary, JSON.stringify(job), { mode: 0o600 }); + await rename(temporary, path); + } + // Reclaim a crashed worker's lease only after proving its process is gone. + // A missing/malformed owner stays closed for operator reconciliation. + private async lock(id: string): Promise<() => Promise> { + const folder = this.folder(id); + const lock = join(folder, "lock"); + const reclaim = join(folder, "reclaim"); + try { + await mkdir(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + try { + await mkdir(reclaim); + } catch { + throw new RosebudError("job_busy", 409); + } + try { + const owner = JSON.parse(await readFile(join(lock, "owner.json"), "utf8")); + if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error("invalid owner"); + let dead = + owner.pid === process.pid && + typeof owner.instance === "string" && + owner.instance !== processInstance; + try { + process.kill(owner.pid, 0); + } catch (cause) { + dead = (cause as NodeJS.ErrnoException).code === "ESRCH"; + } + if (!dead) throw new RosebudError("job_busy", 409); + await rm(lock, { recursive: true }); + await mkdir(lock); + } catch { + throw new RosebudError("job_busy", 409); + } finally { + await rm(reclaim, { recursive: true, force: true }); + } + } + await writeFile( + join(lock, "owner.json"), + JSON.stringify({ pid: process.pid, instance: processInstance }), + { + mode: 0o600, + } + ); + return () => rm(lock, { recursive: true, force: true }); + } + async read(profile: string, id: string): Promise { + const job = JSON.parse(await readFile(join(this.folder(id), "job.json"), "utf8")) as RosebudJob; + if (job.profile !== profile) throw new RosebudError("job_not_found", 404); + return job; + } + async generate(profile: string, raw: unknown): Promise { + const request = generationInput.parse(raw); + const modelId = request.model.replace(/^rosebud\//, ""); + const model = (await this.models(profile)).find((entry) => entry.id === modelId); + if (!model) throw new RosebudError("unknown_model", 400); + if (request.category && request.category !== model.category) + throw new RosebudError("unsupported_modality", 400); + const input = validateRosebudInput(model, request.input); + const fingerprint = hash(JSON.stringify([modelId, ordered(input), request.maxCredits ?? null])); + const id = hash(JSON.stringify([profile, request.idempotencyKey ?? randomUUID()])); + const folder = this.folder(id); + await mkdir(folder, { recursive: true, mode: 0o700 }); + let existing: RosebudJob | undefined; + try { + existing = await this.read(profile, id); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (existing && existing.fingerprint !== fingerprint) + throw new RosebudError("idempotency_conflict", 409); + if (existing) return existing.upstreamId ? this.refresh(profile, id) : existing; + const unlock = await this.lock(id); + const job: RosebudJob = { + id, + profile, + model: modelId, + category: model.category, + input, + fingerprint, + maxCredits: request.maxCredits, + status: "submitting", + updatedAt: new Date().toISOString(), + }; + try { + await this.save(job); + const quote = await this.quote(profile, modelId, input); + if (typeof quote.credits === "number") job.estimatedCredits = quote.credits; + await writeFile(join(folder, "input.json"), JSON.stringify(input), { mode: 0o600 }); + await this.save(job); + return await this.submit(job, request.maxCredits); + } catch (error) { + job.status = error instanceof RosebudError && error.uncertain ? "uncertain" : "failed"; + job.error = error instanceof RosebudError ? error.code : "generation_failed"; + await this.save(job); + if (error instanceof RosebudError) error.jobId = job.id; + throw error; + } finally { + await unlock(); + } + } + private async submit(job: RosebudJob, maxCredits?: number) { + const args = [ + "assets", + "create", + "--model", + job.model, + "--input-json", + join(this.folder(job.id), "input.json"), + "--no-wait", + ]; + if (maxCredits !== undefined) args.push("--max-credits", String(maxCredits)); + const result = await this.run(job.profile, args); + this.apply(job, result); + await this.save(job); + return job; + } + private apply(job: RosebudJob, result: JsonObject) { + if (typeof result.id === "string") job.upstreamId = result.id; + if (typeof result.status === "string") job.status = result.status; + const asset = result.asset as JsonObject | undefined; + if (asset && typeof asset.id === "string") job.assetId = asset.id; + if (typeof result.credits === "number") job.actualCredits = result.credits; + if (typeof result.credits_refunded === "number") job.refundedCredits = result.credits_refunded; + } + async refresh(profile: string, id: string) { + const job = await this.read(profile, id); + if (job.upstreamId && !["succeeded", "failed", "cancelled"].includes(job.status)) { + this.apply(job, await this.run(profile, ["assets", "status", job.upstreamId])); + await this.save(job); + } + return job; + } + /** Re-run only the identical CLI command; its durable pending state owns the upstream idempotency key. */ + async recover(profile: string, id: string) { + const job = await this.read(profile, id); + if (job.upstreamId) return this.refresh(profile, id); + if (!["uncertain", "submitting"].includes(job.status)) return job; + const pendingFingerprint = hash( + JSON.stringify([ + job.model, + Object.keys(job.input) + .sort() + .map((key) => [key, job.input[key]]), + ]) + ); + const folder = await rosebudProfile(profile); + let durablePending = false; + for (const name of await readdir(folder)) { + if (!/^asset-[a-f0-9]+\.json$/.test(name)) continue; + const saved = JSON.parse(await readFile(join(folder, name), "utf8")); + if (saved.fingerprint === pendingFingerprint && typeof saved.idempotency_key === "string") + durablePending = true; + } + // The CLI may have acknowledged and removed its pending record before its + // stdout was lost. Without a durable upstream key, never pay a second time. + if (!durablePending) throw new RosebudError("manual_reconciliation_required", 409, true); + const unlock = await this.lock(id); + try { + return await this.submit(job, job.maxCredits); + } finally { + await unlock(); + } + } + async artifact(profile: string, id: string) { + const job = await this.refresh(profile, id); + if (job.status !== "succeeded" || !job.assetId) throw new RosebudError("asset_not_ready", 409); + const file = join(this.folder(id), "artifact"); + try { + await stat(file); + } catch { + await this.run(profile, ["assets", "download", job.assetId, "--out", file]); + } + const info = await stat(file); + if (!info.isFile() || info.size > 128 * 1024 * 1024) + throw new RosebudError("artifact_too_large", 413); + const data = await readFile(file); + const mime = rosebudArtifactType(data, job.category); + return { data, job, mime, sha256: createHash("sha256").update(data).digest("hex") }; + } + async list(profile: string) { + return this.run(profile, ["assets", "list"]); + } + async downloadAsset(profile: string, assetId: string) { + z.string().uuid().parse(assetId); + const folder = join(rosebudRoot(), "downloads", profile, assetId); + await mkdir(folder, { recursive: true, mode: 0o700 }); + const file = join(folder, "artifact"); + try { + await stat(file); + } catch { + await this.run(profile, ["assets", "download", assetId, "--out", file]); + } + const info = await stat(file); + if (!info.isFile() || info.size > 128 * 1024 * 1024) + throw new RosebudError("artifact_too_large", 413); + const data = await readFile(file); + return { + assetId, + b64_data: data.toString("base64"), + mime_type: rosebudArtifactType(data, "any"), + sha256: createHash("sha256").update(data).digest("hex"), + }; + } +} +export const rosebudRuntime = new RosebudRuntime(); diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 57a4ac80e8e3..acaeb5b19675 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1,3 +1,4 @@ +import { getRosebudUsage } from "./usage/rosebud.ts"; /** * Usage Fetcher - Get usage data from provider APIs * @@ -154,6 +155,8 @@ export async function getUsageForProvider( } switch (provider) { + case "rosebud": + return getRosebudUsage(connection.providerSpecificData); case "github": return await getGitHubUsage(accessToken, providerSpecificData, id || ""); case "antigravity": diff --git a/open-sse/services/usage/fetcherProviders.ts b/open-sse/services/usage/fetcherProviders.ts index c4318fb1e280..5275eea2e0c2 100644 --- a/open-sse/services/usage/fetcherProviders.ts +++ b/open-sse/services/usage/fetcherProviders.ts @@ -21,6 +21,7 @@ * If you add a new provider to the switch in `services/usage.ts`, add it here too. */ export const USAGE_FETCHER_PROVIDERS = [ + "rosebud", "github", "antigravity", "agy", diff --git a/open-sse/services/usage/rosebud.ts b/open-sse/services/usage/rosebud.ts new file mode 100644 index 000000000000..c2d2ba0832f5 --- /dev/null +++ b/open-sse/services/usage/rosebud.ts @@ -0,0 +1,17 @@ +/** Read the last verified CLI account snapshot without spawning from a remote quota request. */ +export function getRosebudUsage(data: Record = {}) { + const identity = data.rosebudAccount as + { status?: string; account?: { display_name?: string; balance?: number } } | undefined; + return { + plan: "Rosebud subscription", + quotas: {}, + accountName: identity?.account?.display_name, + authStatus: identity?.status || "unknown", + remainingCredits: identity?.account?.balance ?? null, + creditUnit: "rosebud_credits", + usdCost: null, + updatedAt: data.rosebudLastChecked || null, + message: + "Native Rosebud credits. Refresh authorization and credits on the Rosebud provider page for a live balance.", + }; +} diff --git a/open-sse/services/usage/supportedProviders.ts b/open-sse/services/usage/supportedProviders.ts index 88dfa3183d44..701c5d6ccf6a 100644 --- a/open-sse/services/usage/supportedProviders.ts +++ b/open-sse/services/usage/supportedProviders.ts @@ -23,6 +23,7 @@ export const USAGE_SUPPORTED_PROVIDERS: readonly string[] = [ "agy", "kiro", "amazon-q", + "rosebud", "github", "codex", "claude", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 9818d6485553..cdb3b5034973 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -1,4 +1,5 @@ "use client"; +import RosebudPanel from "./components/RosebudPanel"; // Issue #3501 strangler-fig decomposition — Phase 1t (final push) import { useState, useEffect, useCallback, useMemo } from "react"; @@ -876,6 +877,9 @@ export default function ProviderDetailPageClient() { {isSearchProvider && } {/* Playground + param filters — extracted to components/ProviderExtraPanels.tsx (#6649) */} + {providerId === "rosebud" && ( + + )} unknown; +}) { + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [approval, setApproval] = useState(null); + async function call(action: string, connectionId?: string) { + setBusy(true); + setMessage(""); + try { + const csrfResponse = await fetch("/api/auth/csrf"); + const csrf = await csrfResponse.json(); + const response = await fetch("/api/rosebud/" + action, { + method: "POST", + headers: { "Content-Type": "application/json", "x-omniroute-csrf": csrf.token }, + body: JSON.stringify(connectionId ? { connectionId } : { name: "OmniRoute Rosebud" }), + }); + const result = await response.json(); + if (!response.ok) throw new Error("Rosebud request failed; check the server connection."); + if ( + ["connect", "authorize"].includes(action) && + typeof result.approval_url === "string" && + new URL(result.approval_url).origin === "https://rosebud.ai" + ) + setApproval(result.approval_url); + if (action === "account") + setMessage( + `${result.status}: ${result.account?.display_name || "Rosebud account"} — ${result.account?.balance ?? "unavailable"} credits` + ); + if (action === "models") + setMessage( + `${result.models?.length || 0} models discovered. Prices are in Rosebud credits.` + ); + refresh(); + } catch { + setMessage( + "Unable to complete this request. Rosebud authorization runs on the OmniRoute server and requires local or LAN access." + ); + } finally { + setBusy(false); + } + } + return ( + +

Rosebud subscription

+

Authorize each account once. Generation uses its existing Rosebud credits.

+ + {approval && ( + + Approve on Rosebud + + )} + {connections.map((connection) => ( +
+ {connection.name || "Rosebud"} + + + +
+ ))} + {message &&

{message}

} +
+ ); +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 0ad8faac8f61..0994e5d719bf 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1,3 +1,4 @@ +import { discoverRosebud } from "@/lib/rosebud/provider"; import { NextResponse } from "next/server"; import { getProviderConnectionFamilyIds, @@ -227,6 +228,11 @@ export async function GET( if (!provider) { return NextResponse.json({ error: "Invalid connection provider" }, { status: 400 }); } + if (provider === "rosebud") { + if (!["loopback", "lan"].includes(request.headers.get(AUTHZ_HEADER_PEER_LOCALITY) || "")) + return errorResponse(403, "Rosebud discovery requires local or LAN access"); + return NextResponse.json(await discoverRosebud(id)); + } const usesCuratedModelsOnly = providerUsesCuratedModelsOnly(provider); // Resolve proxy for this provider (provider-level → global → direct) diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index f94b44d91f15..83fe477b99d3 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,3 +1,5 @@ +import { discoverRosebud } from "@/lib/rosebud/provider"; +import { getRequestPeerLocality } from "@/shared/utils/apiAuth"; import { NextResponse } from "next/server"; import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; import { getCachedProviderConnectionById } from "@/lib/db/readCache"; @@ -439,6 +441,19 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + if (connection.provider === "rosebud") { + if (getRequestPeerLocality(request) === "remote") + return NextResponse.json({ error: "Local Rosebud access required" }, { status: 403 }); + const catalog = await discoverRosebud(id); + const images = catalog.models.filter((model) => model.type === "image"); + return NextResponse.json({ + ok: true, + provider: "rosebud", + syncedModels: images.length, + availableModelsCount: catalog.models.length, + importedModels: images, + }); + } logProvider = toNonEmptyString(connection.provider) || "unknown"; channelLabel = getModelSyncChannelLabel(connection); diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 16849f0a4c50..144dc58ee114 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -1,3 +1,4 @@ +import { rosebudAccount } from "@/lib/rosebud/provider"; import { NextResponse } from "next/server"; import { z } from "zod"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -987,6 +988,25 @@ export async function testSingleConnection( latencyMs: 0, }; } + if (provider === "rosebud") { + if (options.allowLocalSpawn === false) + return { valid: false, error: "Local Rosebud access required", latencyMs: 0 }; + const started = Date.now(); + try { + const account = await rosebudAccount(connectionId); + return { + valid: account.status === "active", + error: account.status === "active" ? null : "Rosebud authorization required", + latencyMs: Date.now() - started, + }; + } catch { + return { + valid: false, + error: "Rosebud account check failed", + latencyMs: Date.now() - started, + }; + } + } retirement.assertProviderAvailable(provider); let proxyInfo: any = null; diff --git a/src/app/api/rosebud/[action]/route.ts b/src/app/api/rosebud/[action]/route.ts new file mode 100644 index 000000000000..7da28b2ff1fc --- /dev/null +++ b/src/app/api/rosebud/[action]/route.ts @@ -0,0 +1,120 @@ +import { + rosebudPublishInput, + rosebudPublish, +} from "@omniroute/open-sse/services/rosebud/publishing.ts"; +import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + connectRosebud, + generateRosebud, + rosebudAccount, + discoverRosebud, + rosebudConnection, + recordRosebudJob, + publicRosebudJob, +} from "@/lib/rosebud/provider"; +import { rosebudRuntime, generationInput } from "@omniroute/open-sse/services/rosebud/runtime.ts"; +import { RosebudError } from "@omniroute/open-sse/services/rosebud/cli.ts"; +import { runRosebud } from "@omniroute/open-sse/services/rosebud/cli.ts"; + +const requestSchema = z + .object({ + connectionId: z.string().uuid().optional(), + name: z.string().min(1).max(100).optional(), + game: rosebudPublishInput.optional(), + assetId: z.string().uuid().optional(), + jobId: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), + request: generationInput.optional(), + }) + .strict(); +export async function POST(request: Request, context: { params: Promise<{ action: string }> }) { + const denied = await requireManagementAuth(request); + if (denied) return denied; + try { + const args = requestSchema.parse(await request.json()); + const { action } = await context.params; + if (action === "connect") + return Response.json(await connectRosebud(args.name || "OmniRoute Rosebud")); + if (!args.connectionId) + return Response.json({ error: "connectionId required" }, { status: 400 }); + const { connection, profile } = await rosebudConnection(args.connectionId); + if (action === "authorize") { + const result = await runRosebud(profile, [ + "login", + "--agent-client", + "other", + "--name", + "OmniRoute Rosebud", + ]); + return Response.json({ + connectionId: args.connectionId, + status: result.status, + approval_url: result.approval_url, + }); + } + if (action === "account") return Response.json(await rosebudAccount(args.connectionId)); + if (!connection.isActive) throw new RosebudError("account_not_active", 401); + if (["publish", "update", "game_status", "pull"].includes(action)) { + if (!args.game) return Response.json({ error: "game request required" }, { status: 400 }); + return Response.json( + await rosebudPublish( + profile, + action as "publish" | "update" | "game_status" | "pull", + args.game + ) + ); + } + if (action === "models") return Response.json(await discoverRosebud(args.connectionId)); + if (action === "quote" && args.request) + return Response.json( + await rosebudRuntime.quote(profile, args.request.model, args.request.input) + ); + if (action === "list") return Response.json(await rosebudRuntime.list(profile)); + if (action === "generate" && args.request) { + const job = await generateRosebud(args.connectionId, profile, args.request); + recordRosebudJob(args.connectionId, job); + return Response.json(publicRosebudJob(job), { + status: job.status === "succeeded" ? 200 : 202, + }); + } + if (action === "status" && args.jobId) { + const job = await rosebudRuntime.refresh(profile, args.jobId); + recordRosebudJob(args.connectionId, job); + return Response.json(publicRosebudJob(job)); + } + if (action === "recover" && args.jobId) { + const job = await rosebudRuntime.recover(profile, args.jobId); + recordRosebudJob(args.connectionId, job); + return Response.json(publicRosebudJob(job)); + } + if (action === "download" && args.assetId) + return Response.json(await rosebudRuntime.downloadAsset(profile, args.assetId)); + if (action === "download" && args.jobId) { + const result = await rosebudRuntime.artifact(profile, args.jobId); + return Response.json({ + ...publicRosebudJob(result.job), + b64_data: result.data.toString("base64"), + mime_type: result.mime, + sha256: result.sha256, + }); + } + return Response.json({ error: "Unsupported Rosebud operation" }, { status: 400 }); + } catch (error) { + return Response.json( + { + jobId: error instanceof RosebudError ? error.jobId : undefined, + error: + error instanceof RosebudError + ? error.code + : "Invalid Rosebud request or provider unavailable", + }, + { + status: + error instanceof z.ZodError ? 400 : error instanceof RosebudError ? error.status : 502, + } + ); + } +} diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index cbd63cbdc188..5e49482f3d5c 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -294,6 +294,8 @@ async function postHandler(request, context) { } // Get credentials — skip for local providers (authType: "none") + if (provider === "rosebud" && request.headers.has("idempotency-key")) + body.idempotency_key = request.headers.get("idempotency-key"); let credentials = null; if (providerConfig && providerConfig.authType !== "none") { credentials = await getProviderCredentialsWithQuotaPreflight( @@ -434,8 +436,12 @@ async function postHandler(request, context) { // Bill the images actually delivered: a provider that returns fewer than the // requested `n` must not be charged for the missing ones (#16398 follow-up). const n = (result as { data?: { data?: unknown[] } }).data?.data?.length || 1; - const costUsd = await calculateModalCost("image", provider, body.model, { n }); - recordModalCallCost(policy.apiKeyInfo?.id, { provider, model: body.model, costUsd }); + const costUsd = + provider === "rosebud" + ? undefined + : await calculateModalCost("image", provider, body.model, { n }); + if (costUsd !== undefined) + recordModalCallCost(policy.apiKeyInfo?.id, { provider, model: body.model, costUsd }); const headers = new Headers({ "Content-Type": "application/json" }); attachOmniRouteMetaHeaders(headers, { provider, diff --git a/src/lib/db/rosebudCredits.ts b/src/lib/db/rosebudCredits.ts new file mode 100644 index 000000000000..6c8aebfe1e07 --- /dev/null +++ b/src/lib/db/rosebudCredits.ts @@ -0,0 +1,29 @@ +import { getDbInstance } from "./core"; + +export type RosebudCreditEvent = { + id: string; + connectionId: string; + model: string; + modality: string; + status: string; + estimatedCredits?: number; + actualCredits?: number; + refundedCredits?: number; + updatedAt: string; +}; +/** Native units only. Upserts make polling/recovery accounting idempotent. */ +export function saveRosebudCreditEvent(event: RosebudCreditEvent) { + getDbInstance() + .prepare( + "INSERT INTO key_value(namespace,key,value) VALUES(?,?,?) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value" + ) + .run("rosebudCreditEvents:" + event.connectionId, event.id, JSON.stringify(event)); +} +export function getRosebudCreditEvents(connectionId: string): RosebudCreditEvent[] { + const rows = getDbInstance() + .prepare("SELECT value FROM key_value WHERE namespace=?") + .all("rosebudCreditEvents:" + connectionId) as Array<{ value: string }>; + return rows + .map((row) => JSON.parse(row.value) as RosebudCreditEvent) + .filter((row) => row.connectionId === connectionId); +} diff --git a/src/lib/providerModels/modelEndpointGuard.ts b/src/lib/providerModels/modelEndpointGuard.ts index 6fea4a406dd9..d4b56304bf3d 100644 --- a/src/lib/providerModels/modelEndpointGuard.ts +++ b/src/lib/providerModels/modelEndpointGuard.ts @@ -61,6 +61,9 @@ export function getModelEndpointMismatchMessage( model: TaggedModel | null | undefined, requested: RequestedEndpoint ): string | null { + if (fullModelId.startsWith("rosebud/") && requested === "chat") { + return "Rosebud generates assets and does not support chat completions."; + } if (!model?.supportedEndpoints?.length) return null; // Neither a legacy/missing source nor an assumed chat default may impose // a hard upstream gate. Declared, user-owned and specialty-ID inference do. @@ -93,6 +96,8 @@ export async function getRequestedModelEndpointMismatch( fullModelId: string, requested: RequestedEndpoint ): Promise { + if (typeof fullModelId === "string" && fullModelId.startsWith("rosebud/") && requested === "chat") + return "Rosebud generates assets and does not support chat completions."; if (typeof fullModelId !== "string" || !isModelEndpointCapabilityGateEnabled()) return null; const slash = fullModelId.indexOf("/"); if (slash <= 0 || slash >= fullModelId.length - 1) return null; diff --git a/src/lib/rosebud/provider.ts b/src/lib/rosebud/provider.ts new file mode 100644 index 000000000000..00e0de4eaa05 --- /dev/null +++ b/src/lib/rosebud/provider.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import { + getProviderConnectionById, + createProviderConnection, + updateProviderConnection, +} from "@/lib/db/providers"; +import { replaceCustomModels } from "@/lib/db/models"; +import { saveRosebudCreditEvent, getRosebudCreditEvents } from "@/lib/db/rosebudCredits"; +import { rosebudRuntime, type RosebudJob } from "@omniroute/open-sse/services/rosebud/runtime.ts"; +import { RosebudError, runRosebud } from "@omniroute/open-sse/services/rosebud/cli.ts"; +import { rosebudCatalogEntry } from "@omniroute/open-sse/services/rosebud/catalog.ts"; + +export async function rosebudConnection(id: string) { + const connection = await getProviderConnectionById(id); + if (!connection || connection.provider !== "rosebud") + throw new RosebudError("connection_not_found", 404); + const data = (connection.providerSpecificData || {}) as Record; + const profile = data.rosebudProfile; + if (typeof profile !== "string") throw new RosebudError("profile_not_configured", 409); + return { connection, profile }; +} +export async function connectRosebud(name: string) { + const profile = randomUUID(); + const login = await runRosebud(profile, ["login", "--agent-client", "other", "--name", name]); + const connection = await createProviderConnection({ + provider: "rosebud", + authType: "none", + name, + isActive: false, + testStatus: "pending", + providerSpecificData: { rosebudProfile: profile }, + }); + if (!connection) throw new RosebudError("connection_create_failed", 500); + return { connectionId: connection.id, status: login.status, approval_url: login.approval_url }; +} +export async function rosebudAccount(id: string) { + const { profile, connection } = await rosebudConnection(id); + const account = await rosebudRuntime.account(profile); + await updateProviderConnection(id, { + isActive: account.status === "active", + testStatus: account.status === "active" ? "active" : "expired", + providerSpecificData: { + ...((connection.providerSpecificData as Record) || {}), + rosebudAccount: account, + rosebudLastChecked: new Date().toISOString(), + }, + }); + return { + ...account, + status: typeof account.status === "string" ? account.status : "unknown", + usage: getRosebudCreditEvents(id), + creditUnit: "rosebud_credits", + usdCost: null, + }; +} +export async function discoverRosebud(id: string) { + const { profile } = await rosebudConnection(id); + const models = (await rosebudRuntime.models(profile)).map(rosebudCatalogEntry); + // Only images fit this catalog's contract. Other modalities remain discoverable + // through the Rosebud API/MCP catalog; none are fabricated as chat models. + await replaceCustomModels( + "rosebud", + models + .filter( + (model) => + model.type === "image" && + model.rosebud.outputFormats.some((format) => + ["png", "jpg", "jpeg", "webp"].includes(format) + ) + ) + .map((model) => ({ + ...model, + source: "auto-sync", + apiFormat: "images-generations", + supportedEndpoints: ["images"], + })) + ); + return { provider: "rosebud", models, source: "rosebud-cli", creditUnit: "rosebud_credits" }; +} +export function recordRosebudJob(connectionId: string, job: RosebudJob) { + saveRosebudCreditEvent({ + id: job.id, + connectionId, + model: job.model, + modality: job.category, + status: job.status, + estimatedCredits: job.estimatedCredits, + actualCredits: job.actualCredits, + refundedCredits: job.refundedCredits, + updatedAt: job.updatedAt, + }); +} +export async function generateRosebud(connectionId: string, profile: string, request: unknown) { + try { + const job = await rosebudRuntime.generate(profile, request); + recordRosebudJob(connectionId, job); + return job; + } catch (error) { + if (error instanceof RosebudError && error.jobId) { + recordRosebudJob(connectionId, await rosebudRuntime.read(profile, error.jobId)); + } + throw error; + } +} +export function publicRosebudJob(job: RosebudJob) { + return { + id: job.id, + model: "rosebud/" + job.model, + category: job.category, + status: job.status, + estimatedCredits: job.estimatedCredits, + actualCredits: job.actualCredits, + refundedCredits: job.refundedCredits, + creditUnit: "rosebud_credits", + usdCost: null, + error: job.error, + updatedAt: job.updatedAt, + }; +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 7e9e9cb31cad..e2eac62cf4a4 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -32,6 +32,8 @@ import { VNC_ROUTE_PREFIX } from "@/lib/vncSession/manifest"; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ + "/api/rosebud/", // Official local CLI process execution and account authorization. + "/api/mcp/", "/api/cli-tools/runtime/", "/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318) diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index 371dd64fc13f..2ca3730e1bce 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -3,6 +3,20 @@ * Pure data literal; re-exported by the providers.ts barrel. No behavior change. */ export const NOAUTH_PROVIDERS = { + rosebud: { + id: "rosebud", + alias: "rosebud", + name: "Rosebud AI", + icon: "image", + color: "#EB4899", + textIcon: "RB", + website: "https://rosebud.ai", + noAuth: false, + hasFree: false, + serviceKinds: ["image", "video", "music", "tts"], + authHint: + "Authorize the official Rosebud CLI on this server. Each connection has isolated credentials.", + }, "devin-cli-agentic": { id: "devin-cli-agentic", alias: "dva", diff --git a/src/sse/services/imageCredentialRetry.ts b/src/sse/services/imageCredentialRetry.ts index ecfae0f1efe7..336175c42c36 100644 --- a/src/sse/services/imageCredentialRetry.ts +++ b/src/sse/services/imageCredentialRetry.ts @@ -113,6 +113,12 @@ export async function executeImageWithCredentialFallback({ allowedConnections = null, selectNextCredentials = defaultSelectNextCredentials, }: ImageCredentialRetryOptions): Promise { + // Rosebud's durable request identity belongs to one CLI profile. Never rotate + // a possibly paid job to another account after a lost/expired response. + if (provider === "rosebud") { + return { credentials, result: await execute(credentials) }; + } + // Local/no-auth image providers intentionally have no credential row. They // still need one direct attempt, but there is no account identity to refresh // or rotate after a 401. diff --git a/tests/integration/rosebud-live.test.mjs b/tests/integration/rosebud-live.test.mjs new file mode 100644 index 000000000000..d93da4eb9c04 --- /dev/null +++ b/tests/integration/rosebud-live.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +// Explicit opt-in: this test spends the connected account's existing credits. +// Run against a locally reachable candidate with a management-scoped API key. +const enabled = process.env.ROSEBUD_LIVE_TEST === "1"; +test( + "Rosebud image travels through OmniRoute and uses native account credits", + { skip: !enabled, timeout: 720000 }, + async () => { + const base = process.env.OMNIROUTE_BASE_URL || "http://127.0.0.1:20128"; + const key = process.env.OMNIROUTE_API_KEY; + const connectionId = process.env.ROSEBUD_CONNECTION_ID; + assert.ok(key && connectionId, "Set OMNIROUTE_API_KEY and ROSEBUD_CONNECTION_ID"); + const call = async (path, body) => { + const response = await fetch(base + path, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(660000), + }); + const result = await response.json(); + assert.equal(response.ok, true, `HTTP ${response.status}: ${JSON.stringify(result.error)}`); + return result; + }; + const before = await call("/api/rosebud/account", { connectionId }); + assert.equal(before.status, "active"); + const output = await call("/v1/images/generations", { + model: "rosebud/flux-2-flash", + prompt: "A small blue spaceship on a plain white background", + response_format: "b64_json", + idempotency_key: "live-test-" + randomUUID(), + }); + const bytes = Buffer.from(output.data[0].b64_json, "base64"); + assert.deepEqual(bytes.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const after = await call("/api/rosebud/account", { connectionId }); + assert.equal(output.rosebud_usage.unit, "rosebud_credits"); + assert.equal(output.rosebud_usage.usd_cost, null); + assert.ok( + before.account.balance > after.account.balance, + "Existing subscription balance must decrease" + ); + assert.ok( + after.usage.some( + (row) => row.id === output.rosebud_usage.job_id && row.status === "succeeded" + ) + ); + if (typeof output.rosebud_usage.consumed === "number") { + assert.equal(before.account.balance - after.account.balance, output.rosebud_usage.consumed); + } + } +); diff --git a/tests/unit/rosebud-cli.test.ts b/tests/unit/rosebud-cli.test.ts new file mode 100644 index 000000000000..22c60fdb5136 --- /dev/null +++ b/tests/unit/rosebud-cli.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runRosebud } from "../../open-sse/services/rosebud/cli.ts"; + +test("CLI commands for the same account are serialized across concurrent requests", async () => { + const root = await mkdtemp(join(tmpdir(), "rosebud-cli-")); + const script = join(root, "fake.mjs"); + const oldRoot = process.env.ROSEBUD_DATA_DIR, + oldScript = process.env.ROSEBUD_CLI_SCRIPT; + process.env.ROSEBUD_DATA_DIR = root; + process.env.ROSEBUD_CLI_SCRIPT = script; + await writeFile( + script, + `import fs from "node:fs"; import path from "node:path"; +const lock = path.join(process.env.ROSEBUD_CREDENTIALS_DIR, "test.lock"); +fs.closeSync(fs.openSync(lock, "wx")); +setTimeout(() => { fs.unlinkSync(lock); process.stdout.write(JSON.stringify({ok:true,args:process.argv.slice(2)})); }, 50);` + ); + try { + const result = await Promise.all([ + runRosebud("one", ["whoami"]), + runRosebud("one", ["assets", "models"]), + ]); + assert.ok(result.every((row) => row.ok === true)); + } finally { + if (oldRoot === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = oldRoot; + if (oldScript === undefined) delete process.env.ROSEBUD_CLI_SCRIPT; + else process.env.ROSEBUD_CLI_SCRIPT = oldScript; + await rm(root, { recursive: true, force: true }); + } +}); + +test("expired authorization and throttling keep their HTTP status without exposing CLI stderr", async () => { + const root = await mkdtemp(join(tmpdir(), "rosebud-cli-errors-")); + const script = join(root, "fake.mjs"); + const oldRoot = process.env.ROSEBUD_DATA_DIR, + oldScript = process.env.ROSEBUD_CLI_SCRIPT; + process.env.ROSEBUD_DATA_DIR = root; + process.env.ROSEBUD_CLI_SCRIPT = script; + await writeFile( + script, + `process.stderr.write(JSON.stringify({error:{code:process.argv[2],message:"private details"}})); process.exitCode=3;` + ); + try { + for (const [code, status] of [ + ["key_expired", 401], + ["throttled", 429], + ] as const) { + await assert.rejects(runRosebud("one", [code]), (error: unknown) => { + assert.equal((error as { status: number }).status, status); + assert.ok(!(error as Error).message.includes("private details")); + return true; + }); + } + } finally { + if (oldRoot === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = oldRoot; + if (oldScript === undefined) delete process.env.ROSEBUD_CLI_SCRIPT; + else process.env.ROSEBUD_CLI_SCRIPT = oldScript; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/rosebud-image.test.ts b/tests/unit/rosebud-image.test.ts new file mode 100644 index 000000000000..a11000e65b5b --- /dev/null +++ b/tests/unit/rosebud-image.test.ts @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("native image dispatch requires locality and uses the selected connected account", async (context) => { + const folder = await mkdtemp(join(tmpdir(), "rosebud-image-test-")); + const previous = process.env.DATA_DIR; + process.env.DATA_DIR = folder; + const { handleRosebudImageGeneration } = + await import("../../open-sse/handlers/imageGeneration/providers/rosebud.ts"); + const { rosebudRuntime } = await import("../../open-sse/services/rosebud/runtime.ts"); + const { createProviderConnection } = await import("../../src/lib/db/providers.ts"); + const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + try { + const denied = await handleRosebudImageGeneration({ body: {}, peerLocality: "remote" }); + assert.equal(denied.status, 403); + const connection = await createProviderConnection({ + provider: "rosebud", + authType: "none", + isActive: true, + name: "Test account", + providerSpecificData: { rosebudProfile: "isolated-test" }, + }); + assert.ok(connection); + context.mock.method(rosebudRuntime, "models", async () => [ + { id: "test-image", category: "image", output_formats: ["png"] }, + ]); + const job = { + id: "a".repeat(64), + profile: "isolated-test", + model: "test-image", + category: "image", + input: {}, + fingerprint: "test", + status: "succeeded", + estimatedCredits: 250, + actualCredits: 250, + updatedAt: new Date().toISOString(), + }; + context.mock.method(rosebudRuntime, "generate", async (profile: string) => { + assert.equal(profile, "isolated-test"); + return job; + }); + context.mock.method(rosebudRuntime, "artifact", async () => ({ + data: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + job, + mime: "image/png", + sha256: "test", + })); + const result = await handleRosebudImageGeneration({ + body: { model: "rosebud/test-image", prompt: "test" }, + peerLocality: "lan", + credentials: { connectionId: String(connection.id) }, + }); + assert.equal(result.success, true); + if (result.success) { + assert.ok(result.data.data[0].b64_json); + assert.equal(result.data.rosebud_usage.unit, "rosebud_credits"); + assert.equal(result.data.rosebud_usage.usd_cost, null); + } + const unsupported = await handleRosebudImageGeneration({ + body: { model: "rosebud/test-image", rosebud_inputs: { output_format: "svg" } }, + peerLocality: "lan", + credentials: { connectionId: String(connection.id) }, + }); + assert.equal(unsupported.status, 400); + const missing = await handleRosebudImageGeneration({ + body: { model: "rosebud/test-image" }, + peerLocality: "lan", + }); + assert.equal(missing.status, 401); + } finally { + resetDbInstance(); + if (previous === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previous; + await rm(folder, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/rosebud-mcp.test.ts b/tests/unit/rosebud-mcp.test.ts new file mode 100644 index 000000000000..3ec17fb9a012 --- /dev/null +++ b/tests/unit/rosebud-mcp.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { rosebudTools } from "../../open-sse/mcp-server/tools/rosebudTools.ts"; +import { rosebudArtifactType } from "../../open-sse/services/rosebud/artifact.ts"; +import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; + +test("Rosebud tools are distinct, management-scoped and have no default credit cap", () => { + assert.equal(new Set(rosebudTools.map((tool) => tool.name)).size, rosebudTools.length); + for (const tool of rosebudTools) assert.deepEqual(tool.scopes, ["manage"]); + for (const tool of rosebudTools.filter((tool) => tool.name.startsWith("rosebud_generate_"))) { + const input = tool.inputSchema.parse({ + connectionId: "123e4567-e89b-42d3-a456-426614174000", + model: "test", + input: { prompt: "hello" }, + }) as Record; + assert.equal(input.maxCredits, undefined); + assert.ok(input.category); + } +}); +test("process spawning routes are local-only", () => { + for (const action of ["generate", "connect", "account", "publish", "update"]) + assert.equal(isLocalOnlyPath("/api/rosebud/" + action), true); +}); +test("download validation rejects a file in the wrong modality", () => { + const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + assert.equal(rosebudArtifactType(png, "image"), "image/png"); + assert.throws(() => rosebudArtifactType(png, "model_3d"), /invalid_artifact/); + assert.throws( + () => rosebudArtifactType(Buffer.from("not an asset"), "image"), + /invalid_artifact/ + ); +}); diff --git a/tests/unit/rosebud-publishing.test.ts b/tests/unit/rosebud-publishing.test.ts new file mode 100644 index 000000000000..e02442dc7e29 --- /dev/null +++ b/tests/unit/rosebud-publishing.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { rosebudPublish } from "../../open-sse/services/rosebud/publishing.ts"; +import { RosebudError } from "../../open-sse/services/rosebud/cli.ts"; + +test("publishing refuses traversal and never repeats an uncertain first upload", async () => { + const dir = await mkdtemp(join(tmpdir(), "rosebud-publish-")); + const previous = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = dir; + let calls = 0; + const runner = async () => { + calls++; + throw new RosebudError("interrupted", 502, true); + }; + try { + const gameId = randomUUID(); + await assert.rejects( + rosebudPublish("primary", "publish", { gameId, files: { "../private.json": "bad" } }, runner), + /unsafe_game_path/ + ); + assert.equal(calls, 0); + const game = { gameId, files: { "index.html": "

Test

" } }; + await assert.rejects(rosebudPublish("primary", "publish", game, runner), /interrupted/); + await assert.rejects(rosebudPublish("primary", "publish", game, runner), /reconciliation/); + assert.equal(calls, 1); + } finally { + if (previous === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = previous; + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/rosebud-routing.test.ts b/tests/unit/rosebud-routing.test.ts new file mode 100644 index 000000000000..fe74150b48e4 --- /dev/null +++ b/tests/unit/rosebud-routing.test.ts @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { executeImageWithCredentialFallback } from "../../src/sse/services/imageCredentialRetry.ts"; + +test("Rosebud never rotates a paid job to another account after authentication expires", async () => { + let calls = 0; + const result = await executeImageWithCredentialFallback({ + provider: "rosebud", + requestedModel: "flux-2-flash", + credentials: { connectionId: "primary" }, + execute: async () => { + calls++; + return { success: false, status: 401, error: "key_expired" }; + }, + selectNextCredentials: async () => { + throw new Error("must not select a different billing account"); + }, + }); + assert.equal(calls, 1); + assert.equal(result.result.status, 401); +}); diff --git a/tests/unit/rosebud-runtime.test.ts b/tests/unit/rosebud-runtime.test.ts new file mode 100644 index 000000000000..d54f1a6d7394 --- /dev/null +++ b/tests/unit/rosebud-runtime.test.ts @@ -0,0 +1,166 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RosebudRuntime } from "../../open-sse/services/rosebud/runtime.ts"; +import { RosebudError, type RosebudRunner } from "../../open-sse/services/rosebud/cli.ts"; +import { + rosebudModel, + validateRosebudInput, + rosebudCatalogEntry, +} from "../../open-sse/services/rosebud/catalog.ts"; + +const model = rosebudModel.parse({ + id: "test-image", + name: "Test", + category: "image", + inputs: [ + { name: "prompt", type: "string", required: true, max_length: 50 }, + { name: "seed", type: "integer", minimum: 0 }, + { name: "asset_type", type: "enum", values: ["sprite", "any"] }, + ], + output_formats: ["png"], + price: { credits: 250 }, +}); +test("validates provider inputs without interpreting prompt text as shell code", () => { + const prompt = '$(whoami); `id` "quoted"'; + assert.equal(validateRosebudInput(model, { prompt }).prompt, prompt); + assert.throws(() => validateRosebudInput(model, { prompt: "x", secret: true })); + assert.throws(() => validateRosebudInput(model, { prompt: "x", seed: -1 })); + assert.throws(() => validateRosebudInput(model, { prompt: "x", asset_type: "video" })); +}); +test("catalog preserves native prices and never invents chat or USD prices", () => { + assert.deepEqual(rosebudCatalogEntry(model).supportedEndpoints, ["images"]); + assert.equal(rosebudCatalogEntry(model).rosebud.usdCost, null); + assert.deepEqual( + rosebudCatalogEntry(rosebudModel.parse({ ...model, category: "model_3d" })).supportedEndpoints, + [] + ); +}); +test("durable jobs reuse a paid submission, bind account, and reject changed input", async () => { + const dir = await mkdtemp(join(tmpdir(), "rosebud-test-")); + const previous = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = dir; + let submissions = 0; + const calls: string[][] = []; + const runner: RosebudRunner = async (_profile, args) => { + calls.push(args); + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") return { credits: 250 }; + if (args[1] === "create") { + submissions++; + return { id: "remote-job", status: "running", credits: 250 }; + } + if (args[1] === "status") + return { id: "remote-job", status: "succeeded", asset: { id: "asset" }, credits: 250 }; + return {}; + }; + try { + const runtime = new RosebudRuntime(runner); + const request = { model: "test-image", input: { prompt: "sprite" }, idempotencyKey: "one" }; + const first = await runtime.generate("account-a", request); + const resumed = await new RosebudRuntime(runner).generate("account-a", request); + assert.equal(first.id, resumed.id); + assert.equal(resumed.status, "succeeded"); + assert.equal(submissions, 1); + assert.equal( + calls.some((args) => args.includes("--max-credits")), + false + ); + await assert.rejects(runtime.read("account-b", first.id), /job_not_found/); + await assert.rejects( + runtime.generate("account-a", { ...request, input: { prompt: "different" } }), + /idempotency_conflict/ + ); + await assert.rejects( + runtime.generate("account-a", { ...request, category: "music" }), + /unsupported_modality/ + ); + } finally { + if (previous === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = previous; + await rm(dir, { recursive: true, force: true }); + } +}); +test("insufficient credits is not automatically retried", async () => { + const dir = await mkdtemp(join(tmpdir(), "rosebud-credit-test-")); + const previous = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = dir; + let submissions = 0; + const runtime = new RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") return { credits: 250 }; + submissions++; + throw new RosebudError("insufficient_credits", 402); + }); + try { + await assert.rejects( + runtime.generate("account-a", { + model: model.id, + input: { prompt: "x" }, + idempotencyKey: "no-money", + }), + /insufficient_credits/ + ); + assert.equal(submissions, 1); + } finally { + if (previous === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = previous; + await rm(dir, { recursive: true, force: true }); + } +}); + +test("lost acknowledgement without the CLI's durable key never regenerates", async () => { + const dir = await mkdtemp(join(tmpdir(), "rosebud-unknown-test-")); + const previous = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = dir; + let submissions = 0; + const runtime = new RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") return { credits: 250 }; + submissions++; + throw new RosebudError("interrupted", 502, true); + }); + try { + const request = { model: model.id, input: { prompt: "x" }, idempotencyKey: "lost" }; + await assert.rejects(runtime.generate("account-a", request), /interrupted/); + const saved = await runtime.generate("account-a", request); + assert.equal(saved.status, "uncertain"); + await assert.rejects(runtime.recover("account-a", saved.id), /manual_reconciliation_required/); + assert.equal(submissions, 1); + } finally { + if (previous === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = previous; + await rm(dir, { recursive: true, force: true }); + } +}); + +test("concurrent identical requests never submit two paid jobs", async () => { + const dir = await mkdtemp(join(tmpdir(), "rosebud-concurrent-")); + const previous = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = dir; + let submissions = 0; + const runtime = new RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") return { credits: 250 }; + if (args[1] === "create") { + submissions++; + return { id: "remote", status: "running" }; + } + return { id: "remote", status: "succeeded" }; + }); + try { + const request = { model: model.id, input: { prompt: "x" }, idempotencyKey: "same" }; + const results = await Promise.allSettled([ + runtime.generate("a", request), + runtime.generate("a", request), + ]); + assert.ok(results.some((result) => result.status === "fulfilled")); + assert.equal(submissions, 1); + } finally { + if (previous === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = previous; + await rm(dir, { recursive: true, force: true }); + } +}); From 8e41b6995582d39570546eea436fa2fb8e0dce2c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 16:27:52 -0400 Subject: [PATCH 02/20] fix(sse): serialize Rosebud leases across routes and publish complete artifacts --- open-sse/services/rosebud/runtime.ts | 73 ++++++++++++------ tests/unit/rosebud-runtime.test.ts | 111 ++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 24 deletions(-) diff --git a/open-sse/services/rosebud/runtime.ts b/open-sse/services/rosebud/runtime.ts index 924270074158..b69613cf4363 100644 --- a/open-sse/services/rosebud/runtime.ts +++ b/open-sse/services/rosebud/runtime.ts @@ -41,7 +41,9 @@ export type RosebudJob = { error?: string; updatedAt: string; }; -const processInstance = randomUUID(); +const PROCESS_INSTANCE = Symbol.for("omniroute.rosebud.processInstance"); +const processState = globalThis as typeof globalThis & { [PROCESS_INSTANCE]?: string }; +const processInstance = (processState[PROCESS_INSTANCE] ??= randomUUID()); const ordered = (input: JsonObject) => Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b))); const hash = (text: string) => createHash("sha256").update(text).digest("hex"); @@ -254,21 +256,55 @@ export class RosebudRuntime { await unlock(); } } - async artifact(profile: string, id: string) { - const job = await this.refresh(profile, id); - if (job.status !== "succeeded" || !job.assetId) throw new RosebudError("asset_not_ready", 409); - const file = join(this.folder(id), "artifact"); + private async download(profile: string, assetId: string, file: string, category: string) { + let exists = false; try { - await stat(file); - } catch { - await this.run(profile, ["assets", "download", job.assetId, "--out", file]); + exists = (await stat(file)).isFile(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (!exists) { + const temporary = file + "." + randomUUID(); + try { + await this.run(profile, ["assets", "download", assetId, "--out", temporary]); + const info = await stat(temporary); + if (!info.isFile() || info.size > 128 * 1024 * 1024) + throw new RosebudError("artifact_too_large", 413); + rosebudArtifactType(await readFile(temporary), category); + try { + await rename(temporary, file); + } catch (error) { + // Another downloader may have published this same completed asset on Windows. + if (!["EEXIST", "EPERM"].includes((error as NodeJS.ErrnoException).code || "")) + throw error; + await stat(file); + } + } finally { + await rm(temporary, { force: true }); + } } const info = await stat(file); if (!info.isFile() || info.size > 128 * 1024 * 1024) throw new RosebudError("artifact_too_large", 413); const data = await readFile(file); - const mime = rosebudArtifactType(data, job.category); - return { data, job, mime, sha256: createHash("sha256").update(data).digest("hex") }; + return { + data, + mime: rosebudArtifactType(data, category), + sha256: createHash("sha256").update(data).digest("hex"), + }; + } + async artifact(profile: string, id: string) { + const job = await this.refresh(profile, id); + if (job.status !== "succeeded" || !job.assetId) throw new RosebudError("asset_not_ready", 409); + return { + ...(await this.download( + profile, + job.assetId, + join(this.folder(id), "artifact"), + job.category + )), + job, + }; } async list(profile: string) { return this.run(profile, ["assets", "list"]); @@ -277,21 +313,12 @@ export class RosebudRuntime { z.string().uuid().parse(assetId); const folder = join(rosebudRoot(), "downloads", profile, assetId); await mkdir(folder, { recursive: true, mode: 0o700 }); - const file = join(folder, "artifact"); - try { - await stat(file); - } catch { - await this.run(profile, ["assets", "download", assetId, "--out", file]); - } - const info = await stat(file); - if (!info.isFile() || info.size > 128 * 1024 * 1024) - throw new RosebudError("artifact_too_large", 413); - const data = await readFile(file); + const result = await this.download(profile, assetId, join(folder, "artifact"), "any"); return { assetId, - b64_data: data.toString("base64"), - mime_type: rosebudArtifactType(data, "any"), - sha256: createHash("sha256").update(data).digest("hex"), + b64_data: result.data.toString("base64"), + mime_type: result.mime, + sha256: result.sha256, }; } } diff --git a/tests/unit/rosebud-runtime.test.ts b/tests/unit/rosebud-runtime.test.ts index d54f1a6d7394..ac6f918b5bc8 100644 --- a/tests/unit/rosebud-runtime.test.ts +++ b/tests/unit/rosebud-runtime.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { RosebudRuntime } from "../../open-sse/services/rosebud/runtime.ts"; @@ -164,3 +164,112 @@ test("concurrent identical requests never submit two paid jobs", async () => { await rm(dir, { recursive: true, force: true }); } }); + +test("separate route modules respect an active job owner's process lease", async () => { + const dir = await mkdtemp(join(tmpdir(), "rosebud-routes-")); + const previous = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = dir; + let release!: () => void, started!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + const entered = new Promise((resolve) => { + started = resolve; + }); + let submissions = 0; + const first = new RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") { + started(); + await waiting; + return { credits: 250 }; + } + submissions++; + return { id: "first", status: "running" }; + }); + let pending: Promise | undefined; + try { + const request = { model: model.id, input: { prompt: "x" }, idempotencyKey: "shared" }; + pending = first.generate("one", request); + await entered; + const { createHash } = await import("node:crypto"); + const id = createHash("sha256") + .update(JSON.stringify(["one", "shared"])) + .digest("hex"); + // Exercise the pre-save crash window while the original owner remains alive. + await rm(join(dir, "jobs", id, "job.json")); + const other = (await import( + new URL("../../open-sse/services/rosebud/runtime.ts?separate-route", import.meta.url).href + )) as typeof import("../../open-sse/services/rosebud/runtime.ts"); + const second = new other.RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") return { credits: 250 }; + submissions++; + return { id: "second", status: "running" }; + }); + await assert.rejects(second.generate("one", request), /job_busy/); + release(); + await pending; + assert.equal(submissions, 1); + } finally { + release(); + await pending?.catch(() => {}); + if (previous === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = previous; + await rm(dir, { recursive: true, force: true }); + } +}); + +test("concurrent artifact readers never observe a partially downloaded file", async () => { + const dir = await mkdtemp(join(tmpdir(), "rosebud-artifact-")); + const previous = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = dir; + let release!: () => void, started!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const entered = new Promise((resolve) => { + started = resolve; + }); + const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]); + const id = "a".repeat(64); + await mkdir(join(dir, "jobs", id), { recursive: true }); + await writeFile( + join(dir, "jobs", id, "job.json"), + JSON.stringify({ + id, + profile: "one", + model: "test-image", + category: "image", + status: "succeeded", + assetId: "asset", + upstreamId: "remote", + input: {}, + fingerprint: "x", + updatedAt: new Date().toISOString(), + }) + ); + const runtime = new RosebudRuntime(async (_profile, args) => { + const out = args.at(-1)!; + await writeFile(out, png.subarray(0, 4)); + started(); + await gate; + await writeFile(out, png); + return {}; + }); + try { + const first = runtime.artifact("one", id); + await entered; + const second = runtime.artifact("one", id); + await new Promise((resolve) => setTimeout(resolve, 20)); + release(); + const results = await Promise.all([first, second]); + assert.ok(results.every((result) => result.data.equals(png))); + assert.ok((await readFile(join(dir, "jobs", id, "artifact"))).equals(png)); + } finally { + release(); + if (previous === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = previous; + await rm(dir, { recursive: true, force: true }); + } +}); From faee53c8c3e658f1764d0437a227b473cc9d634e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 16:44:13 -0400 Subject: [PATCH 03/20] fix(mcp): preserve Rosebud binary downloads in structured results --- docs/providers/ROSEBUD.md | 2 ++ open-sse/mcp-server/server.ts | 6 ++---- open-sse/mcp-server/tools/rosebudTools.ts | 26 +++++++++++++++++++++++ tests/unit/rosebud-mcp.test.ts | 11 +++++++++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/providers/ROSEBUD.md b/docs/providers/ROSEBUD.md index 2eca3c183f4e..9bbd64b6f340 100644 --- a/docs/providers/ROSEBUD.md +++ b/docs/providers/ROSEBUD.md @@ -157,3 +157,5 @@ Use an account without concurrent external generation when comparing balances. The private job directory is owned by one OmniRoute server process; sharing it between independently running servers is unsupported. A missing or malformed crashed-worker lease requires operator reconciliation rather than guessed retries. + +MCP artifact downloads place intact Base64 data in `structuredContent.b64_data`; the text block contains only metadata so accessibility text shortening cannot corrupt the artifact. HTTP downloads return their existing Base64 JSON field. diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 384bd4ee5f32..79fe9039e5bc 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -1,4 +1,4 @@ -import { rosebudTools } from "./tools/rosebudTools.ts"; +import { rosebudTools, rosebudToolResult } from "./tools/rosebudTools.ts"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { APP_CONFIG } from "@/shared/constants/appConfig"; @@ -957,9 +957,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { tool.name, async (args) => { try { - return { - content: [{ type: "text" as const, text: JSON.stringify(await tool.handler(args)) }], - }; + return rosebudToolResult(await tool.handler(args)); } catch (error) { return { isError: true, diff --git a/open-sse/mcp-server/tools/rosebudTools.ts b/open-sse/mcp-server/tools/rosebudTools.ts index 95038424a5b3..881ae8fe45c5 100644 --- a/open-sse/mcp-server/tools/rosebudTools.ts +++ b/open-sse/mcp-server/tools/rosebudTools.ts @@ -1,3 +1,4 @@ +import type { TextToolResult } from "../toolResult.ts"; import { hasMcpHttpAuthContext, getMcpHttpPeerLocality } from "../httpAuthContext.ts"; import { z } from "zod"; import { rosebudPublishInput } from "../../services/rosebud/publishing.ts"; @@ -127,3 +128,28 @@ export const rosebudTools = [ }) ), ]; + +// Keep binary data out of text blocks, which the accessibility layer may shorten. +export function rosebudToolResult(value: unknown): TextToolResult { + if (value && typeof value === "object" && !Array.isArray(value)) { + const result = value as Record; + if (typeof result.b64_data === "string") { + const { b64_data: artifact, ...metadata } = result; + return { + content: [ + { + type: "text", + text: JSON.stringify({ + ...metadata, + artifactEncoding: "base64", + artifactField: "structuredContent.b64_data", + encodedLength: artifact.length, + }), + }, + ], + structuredContent: result, + }; + } + } + return { content: [{ type: "text", text: JSON.stringify(value) }] }; +} diff --git a/tests/unit/rosebud-mcp.test.ts b/tests/unit/rosebud-mcp.test.ts index 3ec17fb9a012..0ee1fbad62b3 100644 --- a/tests/unit/rosebud-mcp.test.ts +++ b/tests/unit/rosebud-mcp.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { rosebudTools } from "../../open-sse/mcp-server/tools/rosebudTools.ts"; +import { rosebudTools, rosebudToolResult } from "../../open-sse/mcp-server/tools/rosebudTools.ts"; import { rosebudArtifactType } from "../../open-sse/services/rosebud/artifact.ts"; import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; @@ -30,3 +30,12 @@ test("download validation rejects a file in the wrong modality", () => { /invalid_artifact/ ); }); + +test("MCP binary downloads survive text accessibility truncation", () => { + const artifact = Buffer.alloc(100000, 42).toString("base64"); + const result = rosebudToolResult({ b64_data: artifact, mime_type: "image/png" }); + assert.equal(result.structuredContent?.b64_data, artifact); + assert.ok(result.content[0].text.length < 1000); + assert.equal(JSON.parse(result.content[0].text).artifactField, "structuredContent.b64_data"); + assert.equal(rosebudToolResult({ balance: 10 }).structuredContent, undefined); +}); From ba3e7edb094b92f76b1112f483f4f1a36755516e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 17:56:41 -0400 Subject: [PATCH 04/20] fix(sse): pin Rosebud retries and enforce account access --- docs/providers/ROSEBUD.md | 27 ++++ docs/reference/PROVIDER_REFERENCE.md | 5 +- .../imageGeneration/providers/rosebud.ts | 46 +++++- open-sse/services/rosebud/catalog.ts | 3 +- open-sse/services/rosebud/cli.ts | 8 +- open-sse/services/rosebud/imageBinding.ts | 59 +++++++ open-sse/services/rosebud/publishing.ts | 22 ++- open-sse/services/rosebud/runtime.ts | 80 ++++++++-- src/app/api/rosebud/[action]/route.ts | 6 +- src/app/api/v1/images/generations/route.ts | 60 ++++++- src/lib/rosebud/authorization.ts | 22 +++ src/lib/rosebud/provider.ts | 35 +++- tests/unit/rosebud-image.test.ts | 19 +++ tests/unit/rosebud-review.test.ts | 150 ++++++++++++++++++ 14 files changed, 509 insertions(+), 33 deletions(-) create mode 100644 open-sse/services/rosebud/imageBinding.ts create mode 100644 src/lib/rosebud/authorization.ts create mode 100644 tests/unit/rosebud-review.test.ts diff --git a/docs/providers/ROSEBUD.md b/docs/providers/ROSEBUD.md index 9bbd64b6f340..b55cd10057da 100644 --- a/docs/providers/ROSEBUD.md +++ b/docs/providers/ROSEBUD.md @@ -159,3 +159,30 @@ between independently running servers is unsupported. A missing or malformed crashed-worker lease requires operator reconciliation rather than guessed retries. MCP artifact downloads place intact Base64 data in `structuredContent.b64_data`; the text block contains only metadata so accessibility text shortening cannot corrupt the artifact. HTTP downloads return their existing Base64 JSON field. + +## Retry and account permissions + +Image requests carrying an `Idempotency-Key` header (or `idempotency_key` field) +are pinned durably to the original connection, scoped to the client API key. +Retries cannot switch to another account, including when that account is unavailable. +Reusing a key with changed inputs returns 409. Supply a stable key for retryable +client requests; a request without a key represents a new generation. +After submission, pending or artifact-failure responses include `rosebud_usage.job_id` +and return 409 so clients can retrieve the original job instead of submitting again. +Definitively unpaid transient failures may be retried with the same key. + +Every Rosebud HTTP operation requires authenticated management access even when +ordinary dashboard login is disabled. Management API keys must also include the +requested connection in their connection scope. HTTP MCP calls inherit that same +check. Dashboard administrators retain their normal management authority. The +upstream CLI independently enforces asset and game-publishing authorization scopes. + +After an uncertain publish, use `rosebud_game_status` to inspect the original game. +Status remains available while its mutation lock is retained. Reconcile the official +CLI project state before an operator removes a retained `operation.lock`; never +remove it solely to retry a potentially successful publication. + +Deleting an OmniRoute connection does not erase retained jobs, artifacts, or the +external CLI grant. Revoke the grant with the official `rosebud logout` command, +using that connection's private `ROSEBUD_CREDENTIALS_DIR`, before removing its +credential directory. Retain uncertain job evidence until it is reconciled. diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index d50960b4805c..4a27263689ad 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -10,7 +10,7 @@ lastUpdated: 2026-10-29 > Regenerate with: `npm run gen:provider-reference` > **Last generated:** 2026-10-29 -Total providers: **380**. See category breakdown below. +Total providers: **381**. See category breakdown below. ## Categories @@ -34,7 +34,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each --- -## No-auth Providers (no key required) (9) +## No-auth Providers (no key required) (10) | ID | Alias | Name | Tags | Website | Notes | Tool calling | |----|-------|------|------|---------|-------|--------------| @@ -44,6 +44,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `codex-app-server` | `cxa` | OpenAI Codex (App-Server) | No-auth | [link](https://developers.openai.com/codex/cli) | No token stored by OmniRoute. The Codex CLI app-server manages its own ChatGPT sign-in (~/.codex/auth.json, auto-refreshed). Use “Sign in with ChatGPT” if the CLI is not yet authenticated. | — | | `devin-cli-agentic` | `dva` | Devin CLI Agentic Bridge | No-auth | [link](https://docs.devin.ai/work-with-devin/devin-cli) | Authentication is owned by the official Devin CLI in its isolated bridge volume. | emulated | | `duckduckgo-web` | `ddgw` | DuckDuckGo AI Chat | No-auth | [link](https://duckduckgo.com/duckchat) | No credentials required — DuckDuckGo AI Chat is anonymous and free. | emulated | +| `rosebud` | `rosebud` | Rosebud AI | No-auth | [link](https://rosebud.ai) | Authorize the official Rosebud CLI on this server. Each connection has isolated credentials. | — | | `uncloseai` | `unc` | UncloseAI | No-auth | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: turboderp/Qwen3.8-27B-exl3. | emulated | | `veoaifree-web` | `veo-free` | Veo AI Free | No-auth, video | [link](https://veoaifree.com) | No auth required. Rate limited to 6 requests/hour per IP. | — | | `zcode` | `zc` | ZCode (GLM Coding Plan) | No-auth | [link](https://zcode.z.ai) | No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login. | — | diff --git a/open-sse/handlers/imageGeneration/providers/rosebud.ts b/open-sse/handlers/imageGeneration/providers/rosebud.ts index 7acef4c8d1af..dad6132f3083 100644 --- a/open-sse/handlers/imageGeneration/providers/rosebud.ts +++ b/open-sse/handlers/imageGeneration/providers/rosebud.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { rosebudConnection, recordRosebudJob, generateRosebud } from "@/lib/rosebud/provider"; -import { rosebudRuntime } from "../../../services/rosebud/runtime.ts"; +import { rosebudRuntime, type RosebudJob } from "../../../services/rosebud/runtime.ts"; import { RosebudError } from "../../../services/rosebud/cli.ts"; const imageRequest = z @@ -27,7 +27,9 @@ export async function handleRosebudImageGeneration(options: { error: "Rosebud CLI requires a trusted local or LAN caller", }; } + let job: RosebudJob | undefined; try { + if (options.signal?.aborted) throw new RosebudError("request_cancelled", 408); const body = imageRequest.parse(options.body); const id = options.credentials?.connectionId; if (!id) throw new RosebudError("connection_required", 401); @@ -41,6 +43,13 @@ export async function handleRosebudImageGeneration(options: { const selected = catalog.find((model) => model.id === body.model.replace(/^rosebud\//, "")); if (!selected || selected.category !== "image") throw new RosebudError("not_an_image_model", 400); + const defaultFormat = selected.inputs?.find((field) => field.name === "output_format")?.default; + if ( + typeof defaultFormat === "string" && + !input.output_format && + !["png", "jpg", "jpeg", "webp"].includes(defaultFormat) + ) + throw new RosebudError("explicit_raster_format_required", 400); if ( !selected.output_formats.some((format) => ["png", "jpg", "jpeg", "webp"].includes(format)) || (typeof input.output_format === "string" && @@ -48,7 +57,8 @@ export async function handleRosebudImageGeneration(options: { ) { throw new RosebudError("use_asset_tool_for_non_raster_output", 400); } - let job = await generateRosebud(id, profile, { + if (options.signal?.aborted) throw new RosebudError("request_cancelled", 408); + job = await generateRosebud(id, profile, { model: body.model, input, idempotencyKey: body.idempotency_key, @@ -56,10 +66,20 @@ export async function handleRosebudImageGeneration(options: { recordRosebudJob(id, job); const deadline = Date.now() + 600000; while (!["succeeded", "failed", "cancelled"].includes(job.status)) { - if (options.signal?.aborted || Date.now() > deadline) { + if ( + options.signal?.aborted || + Date.now() > deadline || + ["submitting", "uncertain"].includes(job.status) + ) { return { success: false as const, - status: 504, + status: 409, + rosebudUsage: { + job_id: job.id, + status: job.status, + unit: "rosebud_credits", + consumed: job.actualCredits, + }, error: `Rosebud job ${job.id} continues; retrieve it with rosebud_asset_status`, }; } @@ -94,8 +114,24 @@ export async function handleRosebudImageGeneration(options: { return { success: false as const, status: - error instanceof z.ZodError ? 400 : error instanceof RosebudError ? error.status : 502, + job && !(error instanceof z.ZodError) + ? 409 + : error instanceof z.ZodError + ? 400 + : error instanceof RosebudError + ? error.status + : 502, error: error instanceof RosebudError ? error.code : "Rosebud image request failed", + rosebudUsage: job + ? { + job_id: job.id, + status: job.status, + unit: "rosebud_credits", + consumed: job.actualCredits, + } + : error instanceof RosebudError && error.jobId + ? { job_id: error.jobId, unit: "rosebud_credits" } + : undefined, }; } } diff --git a/open-sse/services/rosebud/catalog.ts b/open-sse/services/rosebud/catalog.ts index 859d781a7a76..49f2d76cb3f8 100644 --- a/open-sse/services/rosebud/catalog.ts +++ b/open-sse/services/rosebud/catalog.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { RosebudError } from "./cli.ts"; export const rosebudCategory = z.enum([ "image", @@ -62,7 +63,7 @@ export function validateRosebudInput(model: RosebudModel, input: Record child.kill("SIGKILL"), 5000); }, timeoutMs); - const collect = (chunk: Buffer, error: boolean) => { + const collect = (chunk: string, error: boolean) => { if (stdout.length + stderr.length + chunk.length > 8 * 1024 * 1024) { exceeded = true; child.kill("SIGTERM"); diff --git a/open-sse/services/rosebud/imageBinding.ts b/open-sse/services/rosebud/imageBinding.ts new file mode 100644 index 000000000000..3a600317ed07 --- /dev/null +++ b/open-sse/services/rosebud/imageBinding.ts @@ -0,0 +1,59 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile, link, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { RosebudError, rosebudRoot } from "./cli.ts"; + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => [k, canonical(v)]) + ); + return value; +} + +/** Pin a caller's retry to the original account before another paid submit is possible. */ +export async function rosebudImageBinding( + scope: string, + key: unknown, + body: unknown, + allowed: string[] | null, + candidate?: string +): Promise { + if (key === undefined || key === null) return undefined; + if (typeof key !== "string" || key.length < 1 || key.length > 200) + throw new RosebudError("invalid_idempotency_key", 400); + const digest = (value: unknown) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + const fingerprint = digest(canonical(body)); + const folder = join(rosebudRoot(), "image-bindings"); + const file = join(folder, digest([scope, key]) + ".json"); + if (candidate) { + await mkdir(folder, { recursive: true, mode: 0o700 }); + const temporary = file + "." + randomUUID(); + await writeFile(temporary, JSON.stringify({ connectionId: candidate, fingerprint }), { + mode: 0o600, + }); + try { + await link(temporary, file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } finally { + await rm(temporary, { force: true }); + } + } + let saved; + try { + saved = JSON.parse(await readFile(file, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (saved.fingerprint !== fingerprint) throw new RosebudError("idempotency_conflict", 409); + if (typeof saved.connectionId !== "string") throw new RosebudError("invalid_image_binding", 409); + if (allowed?.length && !allowed.includes(saved.connectionId)) + throw new RosebudError("connection_not_allowed", 403); + return saved.connectionId; +} diff --git a/open-sse/services/rosebud/publishing.ts b/open-sse/services/rosebud/publishing.ts index fd523ab7374f..1ffccf926254 100644 --- a/open-sse/services/rosebud/publishing.ts +++ b/open-sse/services/rosebud/publishing.ts @@ -1,7 +1,13 @@ import { mkdir, writeFile, access, rm } from "node:fs/promises"; import { join } from "node:path"; import { z } from "zod"; -import { runRosebud, rosebudRoot, RosebudError, type RosebudRunner } from "./cli.ts"; +import { + runRosebud, + rosebudRoot, + RosebudError, + rosebudProfile, + type RosebudRunner, +} from "./cli.ts"; export const rosebudPublishInput = z .object({ @@ -19,6 +25,7 @@ export async function rosebudPublish( raw: unknown, run: RosebudRunner = runRosebud ) { + await rosebudProfile(profile); const input = rosebudPublishInput.parse(raw); const gameId = input.gameId || ""; if (!gameId) throw new RosebudError("game_id_required", 400); @@ -26,6 +33,17 @@ export async function rosebudPublish( const state = join(folder, "project.json"), build = join(folder, "build"); await mkdir(build, { recursive: true, mode: 0o700 }); + if (action === "game_status") { + const result = await run(profile, ["status", "--state", state]); + return { + gameId, + status: result.status, + version: result.version, + play_url: result.play_url, + claimed: result.claimed, + requiresAuthorization: Boolean(result.approval_url || result.claim_url), + }; + } const lock = join(folder, "operation.lock"); try { await writeFile(lock, action, { flag: "wx", mode: 0o600 }); @@ -61,7 +79,7 @@ export async function rosebudPublish( await writeFile(file, content, { mode: 0o600 }); } } - const args = [action === "game_status" ? "status" : action, "--state", state]; + const args = [action, "--state", state]; if (["publish", "update", "pull"].includes(action)) args.push("--directory", build); if (input.title) args.push("--title", input.title); if (input.description) args.push("--description", input.description); diff --git a/open-sse/services/rosebud/runtime.ts b/open-sse/services/rosebud/runtime.ts index b69613cf4363..959e6f94ff13 100644 --- a/open-sse/services/rosebud/runtime.ts +++ b/open-sse/services/rosebud/runtime.ts @@ -39,6 +39,7 @@ export type RosebudJob = { refundedCredits?: number; maxCredits?: number; error?: string; + unpaidFailure?: boolean; updatedAt: string; }; const PROCESS_INSTANCE = Symbol.for("omniroute.rosebud.processInstance"); @@ -51,10 +52,13 @@ export class RosebudRuntime { constructor(private run: RosebudRunner = runRosebud) {} async models(profile: string): Promise { const result = await this.run(profile, ["assets", "models"]); - return z - .array(rosebudModel) - .parse(result.models) - .filter((model) => !model.status || model.status === "live"); + if (!Array.isArray(result.models)) throw new RosebudError("invalid_model_catalog", 502); + return result.models.flatMap((entry) => { + const parsed = rosebudModel.safeParse(entry); + return parsed.success && (!parsed.data.status || parsed.data.status === "live") + ? [parsed.data] + : []; + }); } async account(profile: string) { return this.run(profile, ["whoami"]); @@ -163,7 +167,8 @@ export class RosebudRuntime { } if (existing && existing.fingerprint !== fingerprint) throw new RosebudError("idempotency_conflict", 409); - if (existing) return existing.upstreamId ? this.refresh(profile, id) : existing; + if (existing && !existing.unpaidFailure) + return existing.upstreamId ? this.refresh(profile, id) : existing; const unlock = await this.lock(id); const job: RosebudJob = { id, @@ -176,20 +181,50 @@ export class RosebudRuntime { status: "submitting", updatedAt: new Date().toISOString(), }; + let submitted = false; + let persist = false; + let claimed = false; + const claim = join( + rosebudRoot(), + "pending", + hash(JSON.stringify([profile, modelId, ordered(input)])) + ); try { + try { + const latest = await this.read(profile, id); + if (latest.fingerprint !== fingerprint) throw new RosebudError("idempotency_conflict", 409); + if (!latest.unpaidFailure) return latest.upstreamId ? this.refresh(profile, id) : latest; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + persist = true; await this.save(job); const quote = await this.quote(profile, modelId, input); if (typeof quote.credits === "number") job.estimatedCredits = quote.credits; await writeFile(join(folder, "input.json"), JSON.stringify(input), { mode: 0o600 }); await this.save(job); + await mkdir(join(rosebudRoot(), "pending"), { recursive: true, mode: 0o700 }); + try { + await writeFile(claim, job.id, { flag: "wx", mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if ((await readFile(claim, "utf8")) !== job.id) + throw new RosebudError("matching_request_requires_reconciliation", 409); + } + claimed = true; + submitted = true; return await this.submit(job, request.maxCredits); } catch (error) { - job.status = error instanceof RosebudError && error.uncertain ? "uncertain" : "failed"; + if (!persist) throw error; + const uncertain = submitted && (!(error instanceof RosebudError) || error.uncertain); + job.unpaidFailure = !uncertain; + job.status = uncertain ? "uncertain" : "failed"; job.error = error instanceof RosebudError ? error.code : "generation_failed"; await this.save(job); if (error instanceof RosebudError) error.jobId = job.id; throw error; } finally { + if (claimed && (job.upstreamId || job.unpaidFailure)) await rm(claim, { force: true }); await unlock(); } } @@ -227,7 +262,7 @@ export class RosebudRuntime { } /** Re-run only the identical CLI command; its durable pending state owns the upstream idempotency key. */ async recover(profile: string, id: string) { - const job = await this.read(profile, id); + let job = await this.read(profile, id); if (job.upstreamId) return this.refresh(profile, id); if (!["uncertain", "submitting"].includes(job.status)) return job; const pendingFingerprint = hash( @@ -242,16 +277,36 @@ export class RosebudRuntime { let durablePending = false; for (const name of await readdir(folder)) { if (!/^asset-[a-f0-9]+\.json$/.test(name)) continue; - const saved = JSON.parse(await readFile(join(folder, name), "utf8")); - if (saved.fingerprint === pendingFingerprint && typeof saved.idempotency_key === "string") - durablePending = true; + try { + const saved = JSON.parse(await readFile(join(folder, name), "utf8")); + if (saved.fingerprint === pendingFingerprint && typeof saved.idempotency_key === "string") + durablePending = true; + } catch { + /* malformed unrelated state cannot authorize another paid submission */ + } } // The CLI may have acknowledged and removed its pending record before its // stdout was lost. Without a durable upstream key, never pay a second time. - if (!durablePending) throw new RosebudError("manual_reconciliation_required", 409, true); + const claim = join( + rosebudRoot(), + "pending", + hash(JSON.stringify([profile, job.model, ordered(job.input)])) + ); + let owner: string | undefined; + try { + owner = await readFile(claim, "utf8"); + } catch { + /* no provable ownership */ + } + if (!durablePending || owner !== id) + throw new RosebudError("manual_reconciliation_required", 409, true); const unlock = await this.lock(id); try { - return await this.submit(job, job.maxCredits); + job = await this.read(profile, id); + if (job.upstreamId) return this.refresh(profile, id); + const result = await this.submit(job, job.maxCredits); + if (result.upstreamId) await rm(claim, { force: true }); + return result; } finally { await unlock(); } @@ -310,6 +365,7 @@ export class RosebudRuntime { return this.run(profile, ["assets", "list"]); } async downloadAsset(profile: string, assetId: string) { + await rosebudProfile(profile); z.string().uuid().parse(assetId); const folder = join(rosebudRoot(), "downloads", profile, assetId); await mkdir(folder, { recursive: true, mode: 0o700 }); diff --git a/src/app/api/rosebud/[action]/route.ts b/src/app/api/rosebud/[action]/route.ts index 7da28b2ff1fc..cb2be98e7d44 100644 --- a/src/app/api/rosebud/[action]/route.ts +++ b/src/app/api/rosebud/[action]/route.ts @@ -3,7 +3,7 @@ import { rosebudPublish, } from "@omniroute/open-sse/services/rosebud/publishing.ts"; import { z } from "zod"; -import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { requireRosebudAccess } from "@/lib/rosebud/authorization"; import { connectRosebud, generateRosebud, @@ -31,10 +31,10 @@ const requestSchema = z }) .strict(); export async function POST(request: Request, context: { params: Promise<{ action: string }> }) { - const denied = await requireManagementAuth(request); - if (denied) return denied; try { const args = requestSchema.parse(await request.json()); + const denied = await requireRosebudAccess(request, args.connectionId); + if (denied) return denied; const { action } = await context.params; if (action === "connect") return Response.json(await connectRosebud(args.name || "OmniRoute Rosebud")); diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 5e49482f3d5c..1e43bcea136b 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -1,3 +1,5 @@ +import { rosebudImageBinding } from "@omniroute/open-sse/services/rosebud/imageBinding.ts"; +import { RosebudError } from "@omniroute/open-sse/services/rosebud/cli.ts"; import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { getRequestedModelEndpointMismatch } from "@/lib/providerModels/modelEndpointGuard"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; @@ -296,13 +298,33 @@ async function postHandler(request, context) { // Get credentials — skip for local providers (authType: "none") if (provider === "rosebud" && request.headers.has("idempotency-key")) body.idempotency_key = request.headers.get("idempotency-key"); + let rosebudPinned: string | undefined; + const bindRosebud = (candidate?: string) => + rosebudImageBinding( + policy.apiKeyInfo?.id || "dashboard", + body.idempotency_key, + body, + allowedConnections, + candidate + ); + if (provider === "rosebud") { + try { + rosebudPinned = await bindRosebud(); + } catch (error) { + return errorResponse( + error instanceof RosebudError ? error.status : 503, + error instanceof RosebudError ? error.code : "Rosebud retry binding unavailable" + ); + } + } let credentials = null; if (providerConfig && providerConfig.authType !== "none") { credentials = await getProviderCredentialsWithQuotaPreflight( provider, null, allowedConnections, - requestedModel + requestedModel, + rosebudPinned ? { forcedConnectionId: rosebudPinned } : {} ); if (!credentials) { return errorResponse( @@ -370,6 +392,31 @@ async function postHandler(request, context) { } } + if (provider === "rosebud" && credentials?.connectionId) { + try { + const pinned = await bindRosebud(credentials.connectionId); + if (pinned && pinned !== credentials.connectionId) { + credentials = await getProviderCredentialsWithQuotaPreflight( + provider, + null, + allowedConnections, + requestedModel, + { forcedConnectionId: pinned } + ); + if (!credentials || isKeyScopeBlockedCredentials(credentials) || credentials.allRateLimited) + return errorResponse( + 409, + "Original Rosebud account is unavailable; the request will not switch accounts" + ); + } + } catch (error) { + return errorResponse( + error instanceof RosebudError ? error.status : 503, + error instanceof RosebudError ? error.code : "Rosebud retry binding unavailable" + ); + } + } + const execution = await executeImageWithCredentialFallback({ provider, requestedModel, @@ -466,7 +513,16 @@ async function postHandler(request, context) { typeof errorPayload?.error?.message === "string" ? errorPayload.error.message : "Image generation provider error"; - return errorResponse((result as any).status, message); + const response = errorResponse((result as any).status, message); + if (provider === "rosebud") { + const usage = (result as { rosebudUsage?: unknown }).rosebudUsage; + if (usage) + return Response.json( + { ...(await response.json()), rosebud_usage: usage }, + { status: response.status } + ); + } + return response; } const guardedPost = withInjectionGuard(postHandler); diff --git a/src/lib/rosebud/authorization.ts b/src/lib/rosebud/authorization.ts new file mode 100644 index 000000000000..98acf2ed495b --- /dev/null +++ b/src/lib/rosebud/authorization.ts @@ -0,0 +1,22 @@ +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { extractApiKey } from "@/sse/services/auth"; +import { getApiKeyMetadata } from "@/lib/db/apiKeys"; +import { resolveKeyConnectionScope } from "@/sse/services/keyConnectionScope"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; + +export async function requireRosebudAccess(request: Request, connectionId?: string) { + const denied = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (denied) return denied; + const key = extractApiKey(request, { allowUrl: false }); + if (!key) return null; // authenticated dashboard / trusted management identity + const metadata = await getApiKeyMetadata(key); + if (!metadata) return errorResponse(403, "Invalid management key"); + const scope = await resolveKeyConnectionScope(metadata); + if (scope.rejection) return scope.rejection; + if ( + scope.allowedConnections?.length && + (!connectionId || !scope.allowedConnections.includes(connectionId)) + ) + return errorResponse(403, "Rosebud connection is outside this API key's scope"); + return null; +} diff --git a/src/lib/rosebud/provider.ts b/src/lib/rosebud/provider.ts index 00e0de4eaa05..4242f980f538 100644 --- a/src/lib/rosebud/provider.ts +++ b/src/lib/rosebud/provider.ts @@ -1,13 +1,18 @@ import { randomUUID } from "node:crypto"; import { getProviderConnectionById, + getProviderConnections, createProviderConnection, updateProviderConnection, } from "@/lib/db/providers"; -import { replaceCustomModels } from "@/lib/db/models"; +import { getCustomModels, replaceCustomModels } from "@/lib/db/models"; import { saveRosebudCreditEvent, getRosebudCreditEvents } from "@/lib/db/rosebudCredits"; import { rosebudRuntime, type RosebudJob } from "@omniroute/open-sse/services/rosebud/runtime.ts"; -import { RosebudError, runRosebud } from "@omniroute/open-sse/services/rosebud/cli.ts"; +import { + RosebudError, + runRosebud, + rosebudProfile, +} from "@omniroute/open-sse/services/rosebud/cli.ts"; import { rosebudCatalogEntry } from "@omniroute/open-sse/services/rosebud/catalog.ts"; export async function rosebudConnection(id: string) { @@ -17,6 +22,7 @@ export async function rosebudConnection(id: string) { const data = (connection.providerSpecificData || {}) as Record; const profile = data.rosebudProfile; if (typeof profile !== "string") throw new RosebudError("profile_not_configured", 409); + await rosebudProfile(profile); return { connection, profile }; } export async function connectRosebud(name: string) { @@ -56,11 +62,32 @@ export async function rosebudAccount(id: string) { export async function discoverRosebud(id: string) { const { profile } = await rosebudConnection(id); const models = (await rosebudRuntime.models(profile)).map(rosebudCatalogEntry); + const connection = await getProviderConnectionById(id); + await updateProviderConnection(id, { + providerSpecificData: { ...(connection?.providerSpecificData || {}), rosebudModels: models }, + }); + const connections = await getProviderConnections({ provider: "rosebud" }); + const merged = new Map(models.map((model) => [model.id, model])); + for (const item of connections) { + const cached = (item.providerSpecificData as Record | undefined) + ?.rosebudModels; + if (item.id !== id && item.isActive && Array.isArray(cached)) + for (const entry of cached) + if ( + entry && + typeof entry === "object" && + typeof entry.id === "string" && + entry.rosebud && + Array.isArray(entry.rosebud.outputFormats) + ) + merged.set(entry.id, entry); + } + const manual = (await getCustomModels("rosebud")).filter((model) => model.source !== "auto-sync"); // Only images fit this catalog's contract. Other modalities remain discoverable // through the Rosebud API/MCP catalog; none are fabricated as chat models. await replaceCustomModels( "rosebud", - models + [...merged.values()] .filter( (model) => model.type === "image" && @@ -74,6 +101,8 @@ export async function discoverRosebud(id: string) { apiFormat: "images-generations", supportedEndpoints: ["images"], })) + .filter((model) => !manual.some((item) => item.id === model.id)) + .concat(manual) ); return { provider: "rosebud", models, source: "rosebud-cli", creditUnit: "rosebud_credits" }; } diff --git a/tests/unit/rosebud-image.test.ts b/tests/unit/rosebud-image.test.ts index a11000e65b5b..8ebdc9780443 100644 --- a/tests/unit/rosebud-image.test.ts +++ b/tests/unit/rosebud-image.test.ts @@ -60,6 +60,25 @@ test("native image dispatch requires locality and uses the selected connected ac assert.equal(result.data.rosebud_usage.unit, "rosebud_credits"); assert.equal(result.data.rosebud_usage.usd_cost, null); } + context.mock.method(rosebudRuntime, "artifact", async () => { + throw new Error("download unavailable"); + }); + const failedDownload = await handleRosebudImageGeneration({ + body: { model: "rosebud/test-image", prompt: "test" }, + peerLocality: "lan", + credentials: { connectionId: String(connection.id) }, + }); + assert.equal(failedDownload.status, 409); + assert.equal(failedDownload.rosebudUsage?.job_id, job.id); + const controller = new AbortController(); + controller.abort(); + const cancelled = await handleRosebudImageGeneration({ + body: { model: "rosebud/test-image", prompt: "test" }, + peerLocality: "lan", + credentials: { connectionId: String(connection.id) }, + signal: controller.signal, + }); + assert.equal(cancelled.status, 408); const unsupported = await handleRosebudImageGeneration({ body: { model: "rosebud/test-image", rosebud_inputs: { output_format: "svg" } }, peerLocality: "lan", diff --git a/tests/unit/rosebud-review.test.ts b/tests/unit/rosebud-review.test.ts new file mode 100644 index 000000000000..9a615bacbb59 --- /dev/null +++ b/tests/unit/rosebud-review.test.ts @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { rosebudImageBinding } from "../../open-sse/services/rosebud/imageBinding.ts"; +import { RosebudRuntime } from "../../open-sse/services/rosebud/runtime.ts"; +import { RosebudError } from "../../open-sse/services/rosebud/cli.ts"; +import { rosebudPublish } from "../../open-sse/services/rosebud/publishing.ts"; + +async function isolated(run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), "rosebud-review-")); + const before = process.env.ROSEBUD_DATA_DIR; + process.env.ROSEBUD_DATA_DIR = root; + try { + await run(root); + } finally { + if (before === undefined) delete process.env.ROSEBUD_DATA_DIR; + else process.env.ROSEBUD_DATA_DIR = before; + await rm(root, { recursive: true, force: true }); + } +} +const model = { + id: "test", + name: "Test", + category: "image", + inputs: [{ name: "prompt", type: "string", required: true }], + output_formats: ["png"], +}; + +test("image idempotency stays pinned across account selection and process restarts", async () => + isolated(async () => { + const body = { model: "rosebud/test", prompt: "hello" }; + const bound = await Promise.all([ + rosebudImageBinding("key-a", "retry", body, null, "account-a"), + rosebudImageBinding("key-a", "retry", body, null, "account-b"), + ]); + assert.equal(bound[0], bound[1]); + assert.equal( + await rosebudImageBinding("key-a", "retry", { prompt: "hello", model: "rosebud/test" }, null), + bound[0] + ); + await assert.rejects( + rosebudImageBinding("key-a", "retry", body, ["other"]), + /connection_not_allowed/ + ); + await assert.rejects( + rosebudImageBinding("key-a", "retry", { ...body, prompt: "changed" }, null), + /idempotency_conflict/ + ); + assert.equal(await rosebudImageBinding("different-caller", "retry", body, null), undefined); + })); + +test("unpaid transient failures can retry the same key; unknown catalog entries do not disable supported models", async () => + isolated(async () => { + let quotes = 0, + submissions = 0; + const runtime = new RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") + return { models: [model, { ...model, id: "future", category: "future-modality" }] }; + if (args[1] === "quote") { + if (++quotes === 1) throw new RosebudError("throttled", 429); + return { credits: 5 }; + } + if (args[1] === "create") { + submissions++; + return { id: "paid", status: "succeeded", credits: 5 }; + } + return {}; + }); + assert.equal((await runtime.models("a")).length, 1); + const request = { model: "test", input: { prompt: "hello" }, idempotencyKey: "retry" }; + await assert.rejects(runtime.generate("a", request), /throttled/); + assert.equal((await runtime.generate("a", request)).upstreamId, "paid"); + assert.equal(submissions, 1); + })); + +test("a second key cannot adopt an uncertain paid request with identical input", async () => + isolated(async () => { + let submits = 0; + const runtime = new RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") return { credits: 5 }; + if (args[1] === "create") { + submits++; + throw new RosebudError("interrupted", 502, true); + } + return {}; + }); + const request = { model: "test", input: { prompt: "hello" }, idempotencyKey: "first" }; + await assert.rejects(runtime.generate("a", request), /interrupted/); + await assert.rejects( + runtime.generate("a", { ...request, idempotencyKey: "second" }), + /matching_request_requires_reconciliation/ + ); + assert.equal(submits, 1); + })); + +test("profile traversal is refused before download or publishing writes; status remains available during reconciliation", async () => + isolated(async (root) => { + const runtime = new RosebudRuntime(async () => ({})); + await assert.rejects(runtime.downloadAsset("../outside", randomUUID()), /invalid_profile/); + await assert.rejects( + rosebudPublish("../outside", "publish", { gameId: randomUUID() }), + /invalid_profile/ + ); + const gameId = randomUUID(); + const folder = join(root, "games", "primary", gameId); + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, "operation.lock"), "uncertain"); + const result = await rosebudPublish( + "primary", + "game_status", + { gameId }, + async (_profile, args) => { + assert.equal(args[0], "status"); + return { status: "active" }; + } + ); + assert.equal(result.status, "active"); + })); + +test("Rosebud requires authenticated management access even in open mode and respects connection allowlists", async () => { + const previousSecret = process.env.API_KEY_SECRET; + process.env.API_KEY_SECRET = "test-only-rosebud-api-key-secret"; + const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); + const { requireRosebudAccess } = await import("../../src/lib/rosebud/authorization.ts"); + const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + try { + assert.equal( + (await requireRosebudAccess(new Request("http://localhost/api/rosebud/account"), "a")) + ?.status, + 401 + ); + const key = await createApiKey("rosebud-restricted", "test-machine", ["manage"], { + allowedConnections: ["a"], + }); + const request = new Request("http://localhost/api/rosebud/account", { + headers: { Authorization: "Bearer " + key.key }, + }); + assert.equal(await requireRosebudAccess(request, "a"), null); + assert.equal((await requireRosebudAccess(request, "b"))?.status, 403); + assert.equal((await requireRosebudAccess(request))?.status, 403); + } finally { + resetDbInstance(); + if (previousSecret === undefined) delete process.env.API_KEY_SECRET; + else process.env.API_KEY_SECRET = previousSecret; + } +}); From 20f2b0f693302648f3c66ea2414c68f74049304a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 17:58:55 -0400 Subject: [PATCH 05/20] fix(sse): preserve legacy Rosebud retries and cancel unpaid work --- .../imageGeneration/providers/rosebud.ts | 15 ++++++++---- open-sse/services/rosebud/runtime.ts | 12 +++++++++- src/app/api/v1/images/generations/route.ts | 5 ++++ src/lib/rosebud/provider.ts | 23 ++++++++++++++++-- tests/unit/rosebud-review.test.ts | 24 +++++++++++++++++++ tests/unit/rosebud-runtime.test.ts | 2 ++ 6 files changed, 73 insertions(+), 8 deletions(-) diff --git a/open-sse/handlers/imageGeneration/providers/rosebud.ts b/open-sse/handlers/imageGeneration/providers/rosebud.ts index dad6132f3083..eef7d350f4e7 100644 --- a/open-sse/handlers/imageGeneration/providers/rosebud.ts +++ b/open-sse/handlers/imageGeneration/providers/rosebud.ts @@ -58,11 +58,16 @@ export async function handleRosebudImageGeneration(options: { throw new RosebudError("use_asset_tool_for_non_raster_output", 400); } if (options.signal?.aborted) throw new RosebudError("request_cancelled", 408); - job = await generateRosebud(id, profile, { - model: body.model, - input, - idempotencyKey: body.idempotency_key, - }); + job = await generateRosebud( + id, + profile, + { + model: body.model, + input, + idempotencyKey: body.idempotency_key, + }, + options.signal + ); recordRosebudJob(id, job); const deadline = Date.now() + 600000; while (!["succeeded", "failed", "cancelled"].includes(job.status)) { diff --git a/open-sse/services/rosebud/runtime.ts b/open-sse/services/rosebud/runtime.ts index 959e6f94ff13..53940e41b474 100644 --- a/open-sse/services/rosebud/runtime.ts +++ b/open-sse/services/rosebud/runtime.ts @@ -147,7 +147,16 @@ export class RosebudRuntime { if (job.profile !== profile) throw new RosebudError("job_not_found", 404); return job; } - async generate(profile: string, raw: unknown): Promise { + async jobForKey(profile: string, key: string): Promise { + await rosebudProfile(profile); + try { + return await this.read(profile, hash(JSON.stringify([profile, key]))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + async generate(profile: string, raw: unknown, signal?: AbortSignal | null): Promise { const request = generationInput.parse(raw); const modelId = request.model.replace(/^rosebud\//, ""); const model = (await this.models(profile)).find((entry) => entry.id === modelId); @@ -203,6 +212,7 @@ export class RosebudRuntime { if (typeof quote.credits === "number") job.estimatedCredits = quote.credits; await writeFile(join(folder, "input.json"), JSON.stringify(input), { mode: 0o600 }); await this.save(job); + if (signal?.aborted) throw new RosebudError("request_cancelled", 408); await mkdir(join(rosebudRoot(), "pending"), { recursive: true, mode: 0o700 }); try { await writeFile(claim, job.id, { flag: "wx", mode: 0o600 }); diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 1e43bcea136b..5b4954af1f14 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -1,3 +1,4 @@ +import { findRosebudImageConnection } from "@/lib/rosebud/provider"; import { rosebudImageBinding } from "@omniroute/open-sse/services/rosebud/imageBinding.ts"; import { RosebudError } from "@omniroute/open-sse/services/rosebud/cli.ts"; import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts"; @@ -310,6 +311,10 @@ async function postHandler(request, context) { if (provider === "rosebud") { try { rosebudPinned = await bindRosebud(); + if (!rosebudPinned && typeof body.idempotency_key === "string") { + const legacy = await findRosebudImageConnection(body.idempotency_key, allowedConnections); + if (legacy) rosebudPinned = await bindRosebud(legacy); + } } catch (error) { return errorResponse( error instanceof RosebudError ? error.status : 503, diff --git a/src/lib/rosebud/provider.ts b/src/lib/rosebud/provider.ts index 4242f980f538..b8bf0bb62b26 100644 --- a/src/lib/rosebud/provider.ts +++ b/src/lib/rosebud/provider.ts @@ -25,6 +25,20 @@ export async function rosebudConnection(id: string) { await rosebudProfile(profile); return { connection, profile }; } +/** Preserve retries of jobs created before the cross-account binding index existed. */ +export async function findRosebudImageConnection(key: string, allowed: string[] | null) { + const matches: string[] = []; + for (const connection of await getProviderConnections({ provider: "rosebud" })) { + if (allowed?.length && !allowed.includes(connection.id)) continue; + const profile = (connection.providerSpecificData as Record | undefined) + ?.rosebudProfile; + if (typeof profile !== "string") continue; + if (await rosebudRuntime.jobForKey(profile, key)) matches.push(connection.id); + } + if (matches.length > 1) + throw new RosebudError("ambiguous_legacy_request_requires_reconciliation", 409); + return matches[0]; +} export async function connectRosebud(name: string) { const profile = randomUUID(); const login = await runRosebud(profile, ["login", "--agent-client", "other", "--name", name]); @@ -119,9 +133,14 @@ export function recordRosebudJob(connectionId: string, job: RosebudJob) { updatedAt: job.updatedAt, }); } -export async function generateRosebud(connectionId: string, profile: string, request: unknown) { +export async function generateRosebud( + connectionId: string, + profile: string, + request: unknown, + signal?: AbortSignal | null +) { try { - const job = await rosebudRuntime.generate(profile, request); + const job = await rosebudRuntime.generate(profile, request, signal); recordRosebudJob(connectionId, job); return job; } catch (error) { diff --git a/tests/unit/rosebud-review.test.ts b/tests/unit/rosebud-review.test.ts index 9a615bacbb59..d2f8e42a5a75 100644 --- a/tests/unit/rosebud-review.test.ts +++ b/tests/unit/rosebud-review.test.ts @@ -148,3 +148,27 @@ test("Rosebud requires authenticated management access even in open mode and res else process.env.API_KEY_SECRET = previousSecret; } }); + +test("cancellation while quoting does not start a paid request", async () => + isolated(async () => { + const controller = new AbortController(); + let submissions = 0; + const runtime = new RosebudRuntime(async (_profile, args) => { + if (args[1] === "models") return { models: [model] }; + if (args[1] === "quote") { + controller.abort(); + return { credits: 5 }; + } + if (args[1] === "create") submissions++; + return {}; + }); + await assert.rejects( + runtime.generate( + "a", + { model: "test", input: { prompt: "hello" }, idempotencyKey: "cancel" }, + controller.signal + ), + /request_cancelled/ + ); + assert.equal(submissions, 0); + })); diff --git a/tests/unit/rosebud-runtime.test.ts b/tests/unit/rosebud-runtime.test.ts index ac6f918b5bc8..06d96db0abbf 100644 --- a/tests/unit/rosebud-runtime.test.ts +++ b/tests/unit/rosebud-runtime.test.ts @@ -60,6 +60,8 @@ test("durable jobs reuse a paid submission, bind account, and reject changed inp const runtime = new RosebudRuntime(runner); const request = { model: "test-image", input: { prompt: "sprite" }, idempotencyKey: "one" }; const first = await runtime.generate("account-a", request); + assert.equal((await runtime.jobForKey("account-a", "one"))?.id, first.id); + assert.equal(await runtime.jobForKey("account-b", "one"), undefined); const resumed = await new RosebudRuntime(runner).generate("account-a", request); assert.equal(first.id, resumed.id); assert.equal(resumed.status, "succeeded"); From 0d35497eda733b96515f119fb65d7f3f726272b9 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 18:07:55 -0400 Subject: [PATCH 06/20] fix(mcp): isolate Rosebud callers and synchronize tool contracts --- AGENTS.md | 2 +- README.md | 8 +- config/quality/eslint-suppressions.json | 5 -- docs/architecture/ARCHITECTURE.md | 2 +- docs/architecture/CODEBASE_DOCUMENTATION.md | 6 +- docs/diagrams/comparison-table.svg | 2 +- docs/diagrams/promise-pillars.svg | 4 +- docs/frameworks/MCP-SERVER.md | 11 +-- docs/i18n/am/llm.txt | 18 ++--- docs/i18n/ar/llm.txt | 18 ++--- docs/i18n/az/llm.txt | 18 ++--- docs/i18n/bg/llm.txt | 18 ++--- docs/i18n/bn/llm.txt | 18 ++--- docs/i18n/bs/llm.txt | 18 ++--- docs/i18n/cs/llm.txt | 18 ++--- docs/i18n/da/llm.txt | 18 ++--- docs/i18n/de/llm.txt | 18 ++--- docs/i18n/el/llm.txt | 18 ++--- docs/i18n/es/llm.txt | 18 ++--- docs/i18n/et/llm.txt | 18 ++--- docs/i18n/fa/llm.txt | 18 ++--- docs/i18n/fi/llm.txt | 18 ++--- docs/i18n/fr/llm.txt | 18 ++--- docs/i18n/ga/llm.txt | 18 ++--- docs/i18n/gu/llm.txt | 18 ++--- docs/i18n/ha/llm.txt | 18 ++--- docs/i18n/he/llm.txt | 18 ++--- docs/i18n/hi/llm.txt | 18 ++--- docs/i18n/hr/llm.txt | 18 ++--- docs/i18n/hu/llm.txt | 18 ++--- docs/i18n/hy/llm.txt | 18 ++--- docs/i18n/id/llm.txt | 18 ++--- docs/i18n/ig/llm.txt | 18 ++--- docs/i18n/it/llm.txt | 18 ++--- docs/i18n/ja/llm.txt | 18 ++--- docs/i18n/ka/llm.txt | 18 ++--- docs/i18n/km/llm.txt | 18 ++--- docs/i18n/kn/llm.txt | 18 ++--- docs/i18n/ko/llm.txt | 18 ++--- docs/i18n/lt/llm.txt | 18 ++--- docs/i18n/lv/llm.txt | 18 ++--- docs/i18n/ml/llm.txt | 18 ++--- docs/i18n/mr/llm.txt | 18 ++--- docs/i18n/ms/llm.txt | 18 ++--- docs/i18n/mt/llm.txt | 18 ++--- docs/i18n/my/llm.txt | 18 ++--- docs/i18n/ne/llm.txt | 18 ++--- docs/i18n/nl/llm.txt | 18 ++--- docs/i18n/no/llm.txt | 18 ++--- docs/i18n/or/llm.txt | 18 ++--- docs/i18n/pa/llm.txt | 18 ++--- docs/i18n/phi/llm.txt | 18 ++--- docs/i18n/pl/llm.txt | 18 ++--- docs/i18n/pt-BR/llm.txt | 18 ++--- docs/i18n/pt/llm.txt | 18 ++--- docs/i18n/ro/llm.txt | 18 ++--- docs/i18n/ru/llm.txt | 18 ++--- docs/i18n/si/llm.txt | 18 ++--- docs/i18n/sk/llm.txt | 18 ++--- docs/i18n/sl/llm.txt | 18 ++--- docs/i18n/sr/llm.txt | 18 ++--- docs/i18n/sv/llm.txt | 18 ++--- docs/i18n/sw/llm.txt | 18 ++--- docs/i18n/ta/llm.txt | 18 ++--- docs/i18n/te/llm.txt | 18 ++--- docs/i18n/th/llm.txt | 18 ++--- docs/i18n/tr/llm.txt | 18 ++--- docs/i18n/uk-UA/llm.txt | 18 ++--- docs/i18n/ur/llm.txt | 18 ++--- docs/i18n/uz/llm.txt | 18 ++--- docs/i18n/vi/llm.txt | 18 ++--- docs/i18n/yo/llm.txt | 18 ++--- docs/i18n/zh-CN/llm.txt | 18 ++--- docs/i18n/zh-TW/llm.txt | 18 ++--- docs/providers/ROSEBUD.md | 7 ++ llm.txt | 18 ++--- .../imageGeneration/providers/rosebud.ts | 4 +- open-sse/mcp-server/README.md | 2 +- open-sse/services/rosebud/artifact.ts | 25 +++++- open-sse/services/rosebud/imageBinding.ts | 10 +++ open-sse/services/rosebud/runtime.ts | 23 +++--- scripts/check/check-docs-counts-sync.mjs | 3 +- skills/omni-mcp/SKILL.md | 10 +-- src/app/api/rosebud/[action]/route.ts | 34 +++++--- src/app/api/v1/images/generations/route.ts | 41 ++++++++-- src/lib/rosebud/authorization.ts | 8 ++ src/lib/rosebud/provider.ts | 49 ++++++++---- tests/unit/rosebud-review.test.ts | 80 ++++++++++++++++++- tests/unit/rosebud-runtime.test.ts | 2 - 89 files changed, 860 insertions(+), 684 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8891ec5c52df..7191af4aa27a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Running tests: see [Testing](#testing) below (run the most focused test for chan - Services — `open-sse/services/` (combo routing, rate limits, caching, etc) - Database — `src/lib/db/` (SQLite domain modules, 213 migrations) - Domain/Policy — `src/domain/` (policy engine, cost rules, fallback logic) -- MCP Server — `open-sse/mcp-server/` (110 tools: 45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules; 3 transports: stdio / SSE / Streamable HTTP; 33 scopes) +- MCP Server — `open-sse/mcp-server/` (129 tools: 45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules; 3 transports: stdio / SSE / Streamable HTTP; 34 scopes) - A2A Server — `src/lib/a2a/` (JSON-RPC 2.0 agent protocol) - Skills — `src/lib/skills/` (extensible skill framework); Memory — `src/lib/memory/` (persistent conversational memory) diff --git a/README.md b/README.md index c9200edb0538..70d14fe5d882 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint and 380 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 380 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 39 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 56,000+ static test declarations across 7,800+ tracked test files. +The Promise — One endpoint and 380 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 380 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 39 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 129 tools, A2A, memory, guardrails, evals and 56,000+ static test declarations across 7,800+ tracked test files.

@@ -502,7 +502,7 @@ All **20** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 380 providers, 150+ free tiers built in, 20 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 67 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 380 providers, 150+ free tiers built in, 20 routing strategies, 12-engine token compression, built-in MCP server with 129 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 67 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -907,7 +907,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo - + @@ -1385,7 +1385,7 @@ Métricas canônicas em 2026-10-18: **1.976 vídeos únicos** · **20.220.289 vi - + diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index df9a212b39f0..75cb5ff56aa1 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1023,11 +1023,6 @@ "count": 1 } }, - "src/app/api/v1/images/generations/route.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/app/api/v1/messages/count_tokens/route.ts": { "no-restricted-imports": { "count": 1 diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index ff11383c91b2..77de659857b5 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -58,7 +58,7 @@ Core capabilities: - Compliance audit logging with opt-out per API key - Eval framework for LLM quality assurance - Health dashboard with real-time provider circuit breaker status -- MCP Server (110 tools) with 3 transports (stdio/SSE/Streamable HTTP) +- MCP Server (129 tools) with 3 transports (stdio/SSE/Streamable HTTP) - A2A Server (JSON-RPC 2.0 + SSE) with skills and task lifecycle - Memory system (extraction, injection, retrieval, summarization) - Skills system (registry, executor, sandbox, built-in skills) diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index 6804bb21e55f..067e047ff421 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -464,7 +464,7 @@ open-sse/ ├── transformer/ Responses API ↔ Chat Completions stream transformer ├── services/ 80+ service modules (combos, fallback, quotas, identity, …) ├── utils/ Streaming helpers, TLS client, AWS SigV4, proxy fetch, … -└── mcp-server/ MCP server (3 transports, 33 scopes, 110 tools) +└── mcp-server/ MCP server (3 transports, 34 scopes, 129 tools) ``` ### 4.1 `open-sse/handlers/` @@ -550,11 +550,11 @@ Highlights (full list under `open-sse/services/`): ### 4.6 `open-sse/mcp-server/` -- **110 unique tools** wired in `server.ts` (45 canonical in `schemas/tools.ts` + +- **129 unique tools** wired in `server.ts` (45 canonical in `schemas/tools.ts` + memory, skills, GitHub-skills, pool, gamification, plugin, Notion, Obsidian, local-corpus and compression modules — union counted by `countUniqueMcpTools`). - **3 transports**: stdio, HTTP Streamable, SSE. -- **33 scopes** enforced at runtime — base list in `src/shared/constants/mcpScopes.ts`, full set is the union of the scopes declared by each tool module. +- **34 scopes** enforced at runtime — base list in `src/shared/constants/mcpScopes.ts`, full set is the union of the scopes declared by each tool module. - Audit table: `mcp_tool_audit` (populated by `audit.ts`). - Files: `server.ts`, `index.ts`, `httpTransport.ts`, `audit.ts`, `scopeEnforcement.ts`, `runtimeHeartbeat.ts`, `descriptionCompressor.ts`, `schemas/{tools, a2a, audit, index}.ts`, diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index ce111ac40d19..4f004cc9b580 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,6 +1,6 @@ Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. - OmniRoute is the only one with the full set: 380 providers, 150+ free providers built-in, 20 routing strategies, 12-engine token compression, a built-in MCP server with 110 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 67 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents. Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. Light and dark follow the viewer's color scheme. + OmniRoute is the only one with the full set: 380 providers, 150+ free providers built-in, 20 routing strategies, 12-engine token compression, a built-in MCP server with 129 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 67 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents. Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. Light and dark follow the viewer's color scheme. OmniRoute — Never stop coding. Every AI tool → 380 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 380 AI providers · 150+ free tiers · ~1.62B free tokens/mo · 20 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 381 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 381 AI providers · 150+ free tiers · ~1.62B free tokens/mo · 20 routing strategies · $0 to start. @@ -242,7 +242,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint and 380 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 380 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 39 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 129 tools, A2A, memory, guardrails, evals and 56,000+ static test declarations across 7,800+ tracked test files. +The Promise — One endpoint and 381 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 381 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 39 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 129 tools, A2A, memory, guardrails, evals and 56,000+ static test declarations across 7,800+ tracked test files.

@@ -502,7 +502,7 @@ All **20** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 380 providers, 150+ free tiers built in, 20 routing strategies, 12-engine token compression, built-in MCP server with 129 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 67 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 381 providers, 150+ free tiers built in, 20 routing strategies, 12-engine token compression, built-in MCP server with 129 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 67 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology.📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -606,7 +606,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute - **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md) - **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md) - **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md) -- **🤝 More providers & agents** — cloud agents (Codex Cloud, Cursor, Devin, Jules), Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **380-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) +- **🤝 More providers & agents** — cloud agents (Codex Cloud, Cursor, Devin, Jules), Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **381-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md) - **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md) - **🧩 Also in the box** — plugin framework + marketplace, Omni/Agent/GitHub skills frameworks, Obsidian vault integration (22 MCP tools), OpenAI-compatible Batch & Files APIs, semantic response cache, gamification with leaderboards, ACP agent discovery (15 built-in agents), scheduled log export to BigQuery, `auto/chaos` parallel multi-model fan-out, a Telegram bot bridge, an in-app version manager and LMArena-ELO free-provider rankings. → [Docs](docs/README.md) @@ -690,11 +690,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
-## 🌐 380 AI Providers — 153 Catalog-Marked Free +## 🌐 381 AI Providers — 153 Catalog-Marked Free
-> **380 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **153 carrying `hasFree: true` discovery metadata**. The chat model registry covers **239 providers / 3,059 distinct provider-model pairs / 1,475 raw model IDs**; the separate free-budget catalog has **471 per-model rows**, **34 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md). +> **381 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **153 carrying `hasFree: true` discovery metadata**. The chat model registry covers **239 providers / 3,059 distinct provider-model pairs / 1,475 raw model IDs**; the separate free-budget catalog has **471 per-model rows**, **34 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 77de659857b5..c5dba8e70c6a 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr Core capabilities: -- OpenAI-compatible API surface for CLI/tools (380 providers, 156 executor modules) +- OpenAI-compatible API surface for CLI/tools (381 providers, 156 executor modules) - Request/response translation across provider formats - Model combo fallback (multi-model sequence) - Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers` diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index 067e047ff421..9e136b3a2a9a 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -501,7 +501,7 @@ helpers (tool schemas, fetch/TTS adapters, quirk handlers). Executors include: (shared identity helper) and `index.ts` (registry). > Note: providers not listed here are served by `default.ts` using the generic -> OpenAI-compatible executor. The full provider catalog (380 providers) lives in +> OpenAI-compatible executor. The full provider catalog (381 providers) lives in > `src/shared/constants/providers.ts`. ### 4.3 `open-sse/translator/` diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index 4d0c90cb9509..d8dc2301e067 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -244,7 +244,7 @@ src/ | -------------------------------- | --------------------------------------------------------------------------------------------------- | | `components/` | Dashboard component API: adapters over `packages/ui-kit` — see `docs/architecture/DESIGN_SYSTEM.md` | | `constants/iconMap.ts` | `ICON_MAP`: icon names used by the screens → Lucide components (``) | -| `constants/providers.ts` | **380 providers** with Zod validation (source of truth) | +| `constants/providers.ts` | **381 providers** with Zod validation (source of truth) | | `constants/cliTools.ts` | External CLI tool registry | | `constants/routingStrategies.ts` | **20 routing strategies** with priorities | | `constants/publicApiRoutes.ts` | Routes that require Bearer (vs management) auth | @@ -271,7 +271,7 @@ open-sse/ ├── translator/ # Format converters (9 request, 9 response, 9 helpers) ├── transformer/ # Responses API ↔ Chat Completions (TransformStream) ├── services/ # ~80+ service modules (combo, accountFallback, autoCombo, reasoningCache, claude code/chatgpt stealth, modelDeprecation, taskAwareRouter, workflowFSM, etc.) -├── mcp-server/ # MCP server (110 tools, 3 transports, 33 scopes) +├── mcp-server/ # MCP server (129 tools, 3 transports, 34 scopes) ├── config/ # Provider/model registries, header config, model aliases ├── utils/ # TLS client, proxy fetch/dispatcher, network helpers ├── index.ts # Workspace entry @@ -414,13 +414,13 @@ open-sse/ | `CLI-TOOLS.md` | External CLI integrations + Internal OmniRoute CLI | | `I18N.md` | i18n architecture, adding a language, 67 locales | | `UNINSTALL.md` | Clean uninstall steps | -| `PROVIDER_REFERENCE.md` | **Auto-generated** catalog of 380 providers (regen: `npm run gen:provider-reference`) | +| `PROVIDER_REFERENCE.md` | **Auto-generated** catalog of 381 providers (regen: `npm run gen:provider-reference`) | ### Subsystem deep-dives | Doc | Purpose | | ------------------------------------------- | -------------------------------------------------------------------- | -| `MCP-SERVER.md` | MCP server: 110 tools, 3 transports, 33 scopes, REST endpoints | +| `MCP-SERVER.md` | MCP server: 129 tools, 3 transports, 34 scopes, REST endpoints | | `A2A-SERVER.md` | A2A v0.3: JSON-RPC, 6 skills, REST helpers, agent card | | `AGENT_PROTOCOLS_GUIDE.md` | Unified guide: A2A vs ACP vs Cloud Agents | | `CLOUD_AGENT.md` | Codex Cloud / Devin / Jules orchestration | diff --git a/docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md b/docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md index 28da515ff19e..0237a74c6c0e 100644 --- a/docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md +++ b/docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md @@ -22,7 +22,7 @@ Objective feature comparison vs popular open-source AI routers. | **Tier 1/2/3 fallback (subscription→cheap→free)** | ✅ + UI | manual | n/a | manual | | **Token compression** | 12-engine stack (RTK + Caveman + LLMLingua) | none | none | none | | **Multimodal generation (speech/music/video)** | ✅ | ❌ | passthrough | ❌ | -| **Built-in MCP server** | ✅ 110 tools, 33 scopes | ❌ | ❌ | ❌ | +| **Built-in MCP server** | ✅ 129 tools, 33 scopes | ❌ | ❌ | ❌ | | **A2A protocol** | ✅ 6 skills | ❌ | ❌ | ❌ | | **Memory (FTS5 + vector)** | ✅ | ❌ | ❌ | ❌ | | **Guardrails (PII, injection, vision)** | ✅ | partial | ❌ | ✅ paid | @@ -41,7 +41,7 @@ Objective feature comparison vs popular open-source AI routers. ## When to choose OmniRoute -- You self-host and want **maximum provider coverage** (380 providers, 155 free/no-auth catalog entries) +- You self-host and want **maximum provider coverage** (381 providers, 155 free/no-auth catalog entries) - You need a **built-in MCP server** (LLM tools, memory, skills exposed as tools) - You need **A2A protocol** for agent-to-agent workflows - You want **fingerprint stealth** (JA3/JA4) to avoid detection by upstream CAPTCHAs diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 56b0af575b67..068f3eabb286 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,5 +1,5 @@ - Animated terminal demoing the OmniRoute CLI: omniroute providers list (380 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over 89 top-level commands: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more. + Animated terminal demoing the OmniRoute CLI: omniroute providers list (381 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over 89 top-level commands: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more. Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen.
InterfaceEndpoint / commandUse it for
🧰 MCP (stdio)omniroute --mcpPlug into Claude Desktop, Cursor, any MCP client
🌊 MCP (HTTP)/api/mcp/streamRemote MCP — 110 tools, 33 scopes (enforcement opt-in), full audit trail
🌊 MCP (HTTP)/api/mcp/streamRemote MCP — 129 tools, 34 scopes (enforcement opt-in), full audit trail
📡 MCP (SSE)/api/mcp/sseStreaming MCP transport
🤝 A2A/.well-known/agent.jsonAgent-to-agent, JSON-RPC 2.0 + SSE, 6 skills
🌐 REST API/v1/*OpenAI-compatible — chat, embeddings, images, audio, OCR
DocumentDescription
API ReferenceAll endpoints with examples
OpenAPI SpecOpenAPI 3.0 specification
MCP Server110 MCP tools, IDE configs, Python/TS/Go clients
MCP Server129 MCP tools, IDE configs, Python/TS/Go clients
MCP Server GuideMCP installation, transports, and tool reference
A2A ServerJSON-RPC 2.0 protocol, skills, streaming, task mgmt
A2A Server GuideA2A agent card, tasks, skills, and streaming