Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,17 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_BASE_URL=
# VISION_BRIDGE_API_KEY=

# ── Raycast Pro (local auto-import) ──
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
# vars are optional manual overrides used by open-sse/services/raycast.ts
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
# RAYCAST_BEARER_TOKEN=
# RAYCAST_DEVICE_ID=
# RAYCAST_AID=
# RAYCAST_SIG_SECRET=

# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,10 @@ Built-in credentials for **localhost development**. For remote deployments, regi
| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. |
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. |
| `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. |
| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. |
| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. |
| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. |
| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. |

> [!WARNING]
>
Expand Down
2 changes: 2 additions & 0 deletions open-sse/config/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { glmProvider } from "./registry/glm/index.ts";
import { glmtProvider } from "./registry/glm/t/index.ts";
import { glm_cnProvider } from "./registry/glm/cn/index.ts";
import { traeProvider } from "./registry/trae/index.ts";
import { raycastProvider } from "./registry/raycast/index.ts";
import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts";
import { lmarenaProvider } from "./registry/lmarena/index.ts";
import { kilocodeProvider } from "./registry/kilocode/index.ts";
Expand Down Expand Up @@ -242,6 +243,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
glmt: glmtProvider,
"glm-cn": glm_cnProvider,
trae: traeProvider,
raycast: raycastProvider,
"muse-spark-web": muse_spark_webProvider,
lmarena: lmarenaProvider,
kilocode: kilocodeProvider,
Expand Down
33 changes: 33 additions & 0 deletions open-sse/config/providers/registry/raycast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @file index.ts
* @description Raycast Pro AI provider registry entry (reverse-engineered, unofficial API).
*
* @changes
* - [2026-07-28] [Composer] - Initial Raycast provider registry module
*/

import type { RegistryEntry } from "../../shared.ts";

/** Seed catalog — full list synced from Raycast /api/v1/ai/models on connect/import. */
export const raycastProvider: RegistryEntry = {
id: "raycast",
alias: "rc",
format: "openai",
executor: "raycast",
baseUrl: "https://backend.raycast.com/api/v1/ai",
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 128000,
models: [
{ id: "openai-gpt-5-mini", name: "GPT-5 Mini" },
{ id: "openai-gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "anthropic-claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "google-gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "raycast-ray1", name: "Ray1" },
{ id: "raycast-ray1-mini", name: "Ray1 Mini" },
{ id: "perplexity-sonar", name: "Sonar" },
{ id: "perplexity-sonar-pro", name: "Sonar Pro" },
{ id: "mistral-open-mistral-nemo", name: "Mistral Nemo" },
{ id: "xai-grok-3-mini", name: "Grok 3 Mini" },
],
};
3 changes: 3 additions & 0 deletions open-sse/executors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { CheaperInferenceExecutor } from "./cheaperinference.ts";
import { KimiWebExecutor } from "./kimi-web.ts";
import { DoubaoWebExecutor } from "./doubao-web.ts";
import { QwenWebExecutor } from "./qwen-web.ts";
import { RaycastExecutor } from "./raycast.ts";
import { HailuoWebExecutor } from "./hailuo-web.ts";
import { ZaiWebExecutor } from "./zai-web.ts";
import { KimiExecutor } from "./kimi.ts";
Expand Down Expand Up @@ -171,6 +172,8 @@ const executors = {
"doubao-web": new DoubaoWebExecutor(),
db: new DoubaoWebExecutor(), // Alias
"qwen-web": new QwenWebExecutor(),
raycast: new RaycastExecutor(),
rc: new RaycastExecutor(), // Alias
"hailuo-web": new HailuoWebExecutor(),
"zai-web": new ZaiWebExecutor(),
zw: new ZaiWebExecutor(), // Alias
Expand Down
224 changes: 224 additions & 0 deletions open-sse/executors/raycast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* @file raycast.ts
* @description Executor for Raycast Pro AI (reverse-engineered backend.raycast.com API).
*
* @changes
* - [2026-07-27] [Composer] - Initial Raycast Pro local-dev executor
*/

import { BaseExecutor, mergeUpstreamExtraHeaders, type ProviderCredentials } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import {
RAYCAST_CHAT_URL,
buildRaycastChatBody,
buildRaycastHeaders,
parseRaycastSseText,
} from "../services/raycast.ts";

type JsonRecord = Record<string, unknown>;
type ChatMessage = { role?: string; content?: unknown };

export class RaycastExecutor extends BaseExecutor {
constructor() {
super("raycast", PROVIDERS.raycast);
}

buildUrl(): string {
return RAYCAST_CHAT_URL;
}

buildHeaders(credentials: ProviderCredentials, payload?: string): Record<string, string> {
const body = payload || "{}";
return buildRaycastHeaders(body, credentials as JsonRecord);
}

async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }) {
const reqBody = body as { messages?: ChatMessage[]; temperature?: number };
let payload: string;

try {
payload = buildRaycastChatBody(model as string, reqBody.messages || [], reqBody.temperature);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
response: new Response(
JSON.stringify({
error: { message: sanitizeErrorMessage(message), type: "invalid_request_error", code: "" },
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers: {},
transformedBody: body,
};
}

const headers = this.buildHeaders(credentials as ProviderCredentials, payload);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record<string, string> | null);

let raycastResponse: Response;
try {
raycastResponse = await fetch(RAYCAST_CHAT_URL, {
method: "POST",
headers,
body: payload,
signal: signal || undefined,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
response: new Response(
JSON.stringify({
error: { message: sanitizeErrorMessage(message), type: "api_error", code: "" },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}

if (!raycastResponse.ok) {
const errorText = await raycastResponse.text();
return {
response: new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(`Raycast API error (${raycastResponse.status})`),
type: "api_error",
code: String(raycastResponse.status),
},
}),
{ status: raycastResponse.status, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}

const responseId = `chatcmpl-raycast-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const modelId = model as string;

if (stream !== false) {
const raycastBody = raycastResponse.body;
if (!raycastBody) {
return {
response: new Response(
JSON.stringify({
error: { message: "Raycast returned empty stream body", type: "api_error", code: "" },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}

const sseStream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const reader = raycastBody.getReader();
const decoder = new TextDecoder();
let buffer = "";

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line.startsWith("data:")) continue;

try {
const data = JSON.parse(line.slice(5).trim()) as {
text?: string;
finish_reason?: string | null;
complete?: boolean;
};
const hasContent = typeof data.text === "string" && data.text.length > 0;
const hasFinishReason =
data.finish_reason !== undefined && data.finish_reason !== null;
if (data.complete || (!hasContent && !hasFinishReason)) continue;

const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model: modelId,
choices: [
{
index: 0,
delta: { content: data.text || "" },
finish_reason: hasFinishReason ? data.finish_reason : null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
} catch {
// Ignore malformed SSE data.
}
}
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch (err) {
controller.error(err);
}
},
});

return {
response: new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}

const responseText = await raycastResponse.text();
const content = parseRaycastSseText(responseText);

return {
response: new Response(
JSON.stringify({
id: responseId,
object: "chat.completion",
created,
model: modelId,
choices: [
{
index: 0,
message: { role: "assistant", content, refusal: null },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}
}
Loading
Loading