diff --git a/README.md b/README.md index 38dffd65d..035f6aa0e 100644 --- a/README.md +++ b/README.md @@ -475,6 +475,8 @@ implementation architecture. | `PLANNOTATOR_ORIGIN` | Override agent detection: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `kiro-cli`, `pi` | | `PLANNOTATOR_JINA` | `0`/`false` to disable Jina Reader for URL annotation | | `JINA_API_KEY` | Jina Reader API key for higher rate limits | +| `ORCAROUTER_API_KEY` | API key for the OrcaRouter Ask AI provider (registers OrcaRouter in Settings > AI) | +| `ORCAROUTER_BASE_URL` | OrcaRouter gateway base URL (default: `https://api.orcarouter.ai/v1`) | | `PLANNOTATOR_DATA_DIR` | Base directory for Plannotator-managed files (plans, history, drafts, `config.json`). Default: `~/.plannotator`; if that directory doesn't exist and `$XDG_DATA_HOME` is set to an absolute path, `$XDG_DATA_HOME/plannotator` is used instead | Plannotator-managed files live under `~/.plannotator` by default. Some UI preferences are stored in functional browser cookies. To relocate the files (for example, for an XDG-clean home): diff --git a/apps/marketing/src/content/docs/guides/ai-features.md b/apps/marketing/src/content/docs/guides/ai-features.md index 2f1c4d43f..62bc6b7e8 100644 --- a/apps/marketing/src/content/docs/guides/ai-features.md +++ b/apps/marketing/src/content/docs/guides/ai-features.md @@ -58,6 +58,21 @@ Requires the `opencode` CLI installed and authenticated. Plannotator spawns `ope OpenCode supports session forking, resuming, and runtime permission approvals — the richest capability set of all four providers. +### OrcaRouter (via OrcaRouter API) + +Requires an `ORCAROUTER_API_KEY`. Unlike the other providers, OrcaRouter is not a local CLI — Plannotator talks to the [OrcaRouter](https://www.orcarouter.ai) gateway directly over its Anthropic-compatible endpoint and streams responses over SSE. It exposes a provider/model namespace across many models (including Claude via OrcaRouter), plus its own adaptive-routing models (`orcarouter/fusion`, `orcarouter/auto`). + +**Models:** + +- OrcaRouter Fusion (default) +- OrcaRouter Fusion Flash +- OrcaRouter Fusion Mini +- OrcaRouter Auto +- Claude Sonnet 5 (via OrcaRouter) +- Claude Haiku 4.5 (via OrcaRouter) + +The gateway is a model endpoint rather than an agent runtime, so OrcaRouter sessions are text-only — no tool execution or permission requests. The API key is read from the server environment and never managed by Plannotator. + ## Configuration Provider and model selection is available in **Settings > AI**. These persist via cookies across sessions. @@ -78,6 +93,8 @@ A session is created lazily on your first question. Until then, no resources are **OpenCode sessions** pass the review context via the `system` field on the prompt API. OpenCode supports forking from a parent session and resuming previous sessions. Permission requests work the same as Claude — approval cards appear inline. +**OrcaRouter sessions** send the review context in the `system` field of the Anthropic Messages API and stream plain text back. OrcaRouter is a stateless model gateway — sessions keep conversation history locally in Plannotator, so follow-up questions stay in context, but there is no forking or resuming. + **Context handling:** Large plans, documents, and diffs are truncated to stay within context limits. When you ask from a selection, the selected text or selected code is always sent alongside the question regardless of truncation. In folder annotation mode, Ask AI is scoped to the currently opened document only. ## Permission requests @@ -90,6 +107,8 @@ OpenCode supports the same permission approval flow as Claude — tool calls tha Pi does not expose a permission approval gate over RPC, so tool execution is handled entirely by Pi's own runtime. +OrcaRouter is a model endpoint with no tool execution, so there are no permission requests. + ## Reasoning effort Codex supports a reasoning effort setting with four levels: **Low**, **Medium**, **High**, and **Max**. This is available in the config bar at the bottom of the AI sidebar. Higher effort means slower but more thorough responses. @@ -100,7 +119,7 @@ This setting only applies to Codex — Claude, Pi, and OpenCode do not expose a | Setting | Description | Provider | |---------|-------------|----------| -| Provider | Claude, Codex, Pi, or OpenCode | All | +| Provider | Claude, Codex, Pi, OpenCode, or OrcaRouter | All | | Model | Model selection per provider | All | | Reasoning effort | Low / Medium / High / Max | Codex only | | Default tools | Read, Glob, Grep, WebSearch | Claude only | diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md index ad0c7aac4..198abbc60 100644 --- a/apps/marketing/src/content/docs/reference/environment-variables.md +++ b/apps/marketing/src/content/docs/reference/environment-variables.md @@ -31,6 +31,13 @@ All Plannotator environment variables and their defaults. \* If you use the VS Code extension, make sure `PLANNOTATOR_DATA_DIR` is visible to both your terminal and VS Code. On macOS, apps launched from the Dock don't inherit shell env vars — launch VS Code from the terminal (`code .`) or set the variable via `launchctl setenv`. +## AI provider variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ORCAROUTER_API_KEY` | (none) | API key for the [OrcaRouter](https://www.orcarouter.ai) Ask AI provider. When set, OrcaRouter is registered as a provider in Settings > AI. The key is read from the server environment and never written to Plannotator-managed files. | +| `ORCAROUTER_BASE_URL` | `https://api.orcarouter.ai/v1` | Base URL of the OrcaRouter gateway. Override when self-hosting or proxying the gateway. | + ## Glimpse (native window) | Variable | Default | Description | diff --git a/packages/ai/index.ts b/packages/ai/index.ts index 18e37b2d3..f52d7f652 100644 --- a/packages/ai/index.ts +++ b/packages/ai/index.ts @@ -75,6 +75,7 @@ export type { CodexSDKConfig, PiSDKConfig, OpenCodeConfig, + OrcaRouterConfig, } from "./types.ts"; // Provider registry diff --git a/packages/ai/package.json b/packages/ai/package.json index c48de6da2..43c3968a5 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -15,6 +15,7 @@ "./providers/command-path": "./providers/command-path.ts", "./providers/pi-sdk": "./providers/pi-sdk.ts", "./providers/opencode-sdk": "./providers/opencode-sdk.ts", + "./providers/orcarouter": "./providers/orcarouter.ts", "./providers/pi-sdk-node": "./providers/pi-sdk-node.ts" }, "dependencies": { diff --git a/packages/ai/providers/orcarouter.test.ts b/packages/ai/providers/orcarouter.test.ts new file mode 100644 index 000000000..4ac29d35d --- /dev/null +++ b/packages/ai/providers/orcarouter.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { splitSseChunks, mapAnthropicSseData } from "./orcarouter.ts"; + +describe("splitSseChunks", () => { + test("splits a single data line", () => { + expect(splitSseChunks("data: hello\n\n")).toEqual(["hello"]); + }); + + test("handles multiple events and CRLF", () => { + const input = + "event: message_start\r\ndata: {\"type\":\"message_start\"}\r\n\r\n" + + "event: content_block_delta\r\ndata: {\"type\":\"content_block_delta\"}\r\n\r\n"; + expect(splitSseChunks(input)).toEqual([ + '{"type":"message_start"}', + '{"type":"content_block_delta"}', + ]); + }); + + test("ignores non-data lines like event and id", () => { + const input = "event: message_stop\ndata: [DONE]\n\n"; + expect(splitSseChunks(input)).toEqual(["[DONE]"]); + }); + + test("trims a trailing carriage return", () => { + const input = "data: {\"type\":\"x\"}\r\n\r\n"; + expect(splitSseChunks(input)).toEqual(['{"type":"x"}']); + }); +}); + +describe("mapAnthropicSseData", () => { + test("maps text_delta deltas", () => { + const messages = mapAnthropicSseData( + '{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}', + "s1", + ); + expect(messages).toEqual([{ type: "text_delta", delta: "Hel" }]); + }); + + test("ignores non-text deltas (thinking, input_json)", () => { + const messages = mapAnthropicSseData( + '{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"..."}}', + "s1", + ); + expect(messages).toEqual([]); + }); + + test("surfaces the session id from message_start as an unknown marker", () => { + const messages = mapAnthropicSseData( + '{"type":"message_start","message":{"id":"msg_abc"}}', + "s1", + ); + expect(messages).toEqual([{ type: "unknown", raw: { sessionId: "msg_abc" } }]); + }); + + test("message_stop yields a successful result", () => { + const messages = mapAnthropicSseData('{"type":"message_stop"}', "s1"); + expect(messages).toEqual([{ type: "result", sessionId: "s1", success: true }]); + }); + + test("maps the error event", () => { + const messages = mapAnthropicSseData( + '{"type":"error","error":{"message":"quota exhausted"}}', + "s1", + ); + expect(messages).toEqual([ + { + type: "error", + error: "quota exhausted", + code: "orcarouter_error", + }, + ]); + }); + + test("passes [DONE] through as an empty list", () => { + expect(mapAnthropicSseData("[DONE]", "s1")).toEqual([]); + }); + + test("falls back to unknown for unparsable data", () => { + const messages = mapAnthropicSseData("not json", "s1"); + expect(messages).toEqual([{ type: "unknown", raw: { raw: "not json" } }]); + }); +}); diff --git a/packages/ai/providers/orcarouter.ts b/packages/ai/providers/orcarouter.ts new file mode 100644 index 000000000..e66cf1ca0 --- /dev/null +++ b/packages/ai/providers/orcarouter.ts @@ -0,0 +1,340 @@ +/** + * OrcaRouter provider — bridges Plannotator's AI layer with OrcaRouter's + * OpenAI-compatible gateway. + * + * OrcaRouter exposes a provider/model namespace over a single endpoint (like + * OpenRouter) and speaks the Anthropic Messages API on `/v1/messages`, so this + * provider needs no local agent CLI: it authenticates with `ORCAROUTER_API_KEY` + * and streams plain text (no tool execution — the gateway is a model endpoint, + * not an agent runtime). + * + * The model catalog is static because the gateway names its own namespace + * (`anthropic/claude-sonnet-5`, `orcarouter/fusion`, ...). No fetchModels() + * discovery is needed, and the UI's model picker works from the first + * capabilities probe. + */ + +import { BaseSession } from "../base-session.ts"; +import { buildSystemPrompt } from "../context.ts"; +import { registerProviderFactory } from "../provider.ts"; +import type { + AIMessage, + AIProvider, + AIProviderCapabilities, + AISession, + CreateSessionOptions, + OrcaRouterConfig, +} from "../types.ts"; + +const PROVIDER_NAME = "orcarouter"; + +/** Default gateway base URL. Override with `ORCAROUTER_BASE_URL`. */ +const DEFAULT_BASE_URL = "https://api.orcarouter.ai/v1"; + +/** + * The models OrcaRouter exposes through the Anthropic-compatible endpoint. + * Kept in sync with `GET /v1/models`. Defaults to `orcarouter/fusion` (the + * adaptive-routing model that selects the best upstream per request). + */ +const DEFAULT_MODELS: ReadonlyArray<{ + id: string; + label: string; + default?: boolean; +}> = [ + { id: "orcarouter/fusion", label: "OrcaRouter Fusion", default: true }, + { id: "orcarouter/fusion-flash", label: "OrcaRouter Fusion Flash" }, + { id: "orcarouter/fusion-mini", label: "OrcaRouter Fusion Mini" }, + { id: "orcarouter/auto", label: "OrcaRouter Auto" }, + { id: "anthropic/claude-sonnet-5", label: "Claude Sonnet 5 (via OrcaRouter)" }, + { id: "anthropic/claude-haiku-4.5", label: "Claude Haiku 4.5 (via OrcaRouter)" }, +]; + +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing) +// --------------------------------------------------------------------------- + +/** + * Split an SSE byte stream into `event:`/`data:` blocks. Each returned chunk + * is one complete `data:` line's value. Handles CRLF and the `\r` that some + * gateways leave on the end of a line. + */ +export function splitSseChunks(input: string): string[] { + const chunks: string[] = []; + for (const block of input.split(/\r?\n\r?\n/)) { + for (const line of block.split(/\r?\n/)) { + if (line.startsWith("data:")) { + chunks.push(line.slice(5).trimStart()); + } + } + } + return chunks; +} + +/** + * Parse one Anthropic Messages SSE data line into an AIMessage. + * + * The gateway streams `message_start`, `content_block_delta`, `message_delta` + * and `message_stop` events; `message_start` also carries the backend session + * (message) id, which we adopt as the resolved session id. + */ +export function mapAnthropicSseData(data: string, currentId: string): AIMessage[] { + if (data === "[DONE]") return []; + let parsed: Record; + try { + parsed = JSON.parse(data); + } catch { + return [{ type: "unknown", raw: { raw: data } }]; + } + + switch (parsed.type) { + case "message_start": { + const message = parsed.message as Record | undefined; + const id = typeof message?.id === "string" ? message.id : undefined; + return id ? [{ type: "unknown", raw: { sessionId: id } }] : []; + } + case "content_block_delta": { + const delta = parsed.delta as Record | undefined; + if (delta?.type === "text_delta" && typeof delta.text === "string") { + return [{ type: "text_delta", delta: delta.text }]; + } + return []; + } + case "message_delta": { + // The stop_reason is carried on the final delta; nothing to surface as a + // message in itself. + return []; + } + case "message_stop": + return [{ type: "result", sessionId: currentId, success: true }]; + case "error": { + const error = parsed.error as Record | undefined; + return [ + { + type: "error", + error: (error?.message as string) ?? "OrcaRouter error", + code: "orcarouter_error", + }, + ]; + } + default: + return []; + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export class OrcaRouterProvider implements AIProvider { + readonly name = PROVIDER_NAME; + readonly capabilities: AIProviderCapabilities = { + fork: false, // the gateway is stateless per message; sessions are local only + resume: false, // no server-side conversation state to resume by id + streaming: true, + tools: false, // model endpoint — no tool execution + }; + readonly models = DEFAULT_MODELS; + + private config: OrcaRouterConfig; + + constructor(config: OrcaRouterConfig) { + this.config = config; + } + + async createSession(options: CreateSessionOptions): Promise { + return new OrcaRouterSession({ + systemPrompt: buildSystemPrompt(options.context), + baseUrl: this.config.baseUrl ?? DEFAULT_BASE_URL, + apiKey: this.config.apiKey ?? "", + model: options.model ?? this.config.model ?? DEFAULT_MODELS[0].id, + parentSessionId: null, + }); + } + + async forkSession(): Promise { + throw new Error( + "OrcaRouter does not support session forking. " + + "The endpoint layer should fall back to createSession().", + ); + } + + async resumeSession(_sessionId: string): Promise { + throw new Error( + "OrcaRouter does not support resuming sessions by id — " + + "the gateway keeps no server-side conversation state. " + + "Create a new session instead.", + ); + } + + dispose(): void { + // No persistent resources to clean up. + } +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +interface SessionConfig { + systemPrompt: string | null; + baseUrl: string; + apiKey: string; + model: string; + parentSessionId: string | null; +} + +interface AnthropicMessage { + role: "user" | "assistant"; + content: string; +} + +class OrcaRouterSession extends BaseSession { + private config: SessionConfig; + private history: AnthropicMessage[] = []; + /** Abort controller for the in-flight fetch; aborted by abort(). */ + private _activeAbort: AbortController | null = null; + + constructor(config: SessionConfig) { + super({ parentSessionId: config.parentSessionId }); + this.config = config; + } + + async *query(prompt: string): AsyncIterable { + const started = this.startQuery(); + if (!started) { + yield BaseSession.BUSY_ERROR; + return; + } + const { gen, signal } = started; + + try { + const messages: AnthropicMessage[] = [ + ...this.history, + { role: "user", content: prompt }, + ]; + // Chain the base session's abort signal (from startQuery) so abort() + // cancels the in-flight fetch. + const abortController = new AbortController(); + this._activeAbort = abortController; + if (signal.aborted) abortController.abort(); + signal.addEventListener("abort", () => abortController.abort(), { once: true }); + const response = await fetch(`${this.config.baseUrl}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${this.config.apiKey}`, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: this.config.model, + max_tokens: 2048, + // The system prompt is seeded once on the first turn via the API's + // own `system` field; later turns keep the full message history and + // no preamble. + ...(this.config.systemPrompt && !this._firstQuerySent + ? { system: this.config.systemPrompt } + : {}), + messages, + stream: true, + }), + signal: abortController.signal, + }); + + if (!response.ok) { + const body = await response.text(); + yield { + type: "error", + error: `OrcaRouter request failed (${response.status}): ${body}`, + code: "orcarouter_http_error", + }; + return; + } + + if (!response.body) { + yield { + type: "error", + error: "OrcaRouter returned an empty response body", + code: "orcarouter_empty_response", + }; + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let assistantText = ""; + let turnDone = false; + + try { + while (!turnDone) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // The gateway flushes complete SSE events; split on the blank line + // that terminates each event block. + let eventEnd = buffer.indexOf("\n\n"); + while (eventEnd !== -1) { + const block = buffer.slice(0, eventEnd); + buffer = buffer.slice(eventEnd + 2); + for (const data of splitSseChunks(block)) { + const messages = mapAnthropicSseData(data, this.id); + for (const message of messages) { + if ( + message.type === "unknown" && + "sessionId" in message.raw && + typeof message.raw.sessionId === "string" + ) { + // Adopt the gateway's message id as the session id so the + // session manager keys this conversation by something stable. + this.resolveId(message.raw.sessionId); + continue; + } + if (message.type === "text_delta") { + assistantText += message.delta; + } + if (message.type === "result") { + turnDone = true; + } + yield message; + } + } + eventEnd = buffer.indexOf("\n\n"); + } + } + } finally { + reader.releaseLock(); + } + + this._firstQuerySent = true; + // Keep the conversation local so subsequent queries continue the thread. + this.history.push({ role: "user", content: prompt }); + if (assistantText) { + this.history.push({ role: "assistant", content: assistantText }); + } + } catch (err) { + yield { + type: "error", + error: err instanceof Error ? err.message : String(err), + code: "provider_error", + }; + } finally { + this._activeAbort = null; + this.endQuery(gen); + } + } + + abort(): void { + this._activeAbort?.abort(); + super.abort(); + } +} + +// --------------------------------------------------------------------------- +// Factory registration +// --------------------------------------------------------------------------- + +registerProviderFactory( + PROVIDER_NAME, + async (config) => new OrcaRouterProvider(config as OrcaRouterConfig), +); diff --git a/packages/ai/types.ts b/packages/ai/types.ts index d64e804ea..85bfeebcf 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -318,3 +318,14 @@ export interface OpenCodeConfig extends AIProviderConfig { /** Port for the OpenCode server. Default: 4096. */ port?: number; } + +export interface OrcaRouterConfig extends AIProviderConfig { + type: "orcarouter"; + /** + * Base URL of the OrcaRouter gateway. Defaults to the public gateway at + * https://api.orcarouter.ai/v1 (override with `ORCAROUTER_BASE_URL`). + */ + baseUrl?: string; + /** API key for the gateway. Read from `ORCAROUTER_API_KEY`. */ + apiKey?: string; +} diff --git a/packages/server/ai-runtime.ts b/packages/server/ai-runtime.ts index 7432a6f70..7dbca7e90 100644 --- a/packages/server/ai-runtime.ts +++ b/packages/server/ai-runtime.ts @@ -5,6 +5,7 @@ import { ProviderRegistry, SessionManager, type AIEndpoints, + type OrcaRouterConfig, type PiSDKConfig, } from "@plannotator/ai"; import { resolveWindowsCommandShim } from "@plannotator/ai/providers/command-path"; @@ -100,6 +101,26 @@ export async function createAIRuntime(options: CreateAIRuntimeOptions = {}): Pro // OpenCode not available. } + // OrcaRouter is a gateway, not a local agent runtime: it activates whenever + // ORCAROUTER_API_KEY is present, no CLI to detect. The key never leaves the + // server and is never logged. + if (process.env.ORCAROUTER_API_KEY) { + try { + await import("@plannotator/ai/providers/orcarouter"); + const provider = await createProvider({ + type: "orcarouter", + cwd, + apiKey: process.env.ORCAROUTER_API_KEY, + ...(process.env.ORCAROUTER_BASE_URL + ? { baseUrl: process.env.ORCAROUTER_BASE_URL } + : {}), + } as OrcaRouterConfig); + registry.register(provider); + } catch { + // OrcaRouter not reachable — skip rather than fail the runtime. + } + } + const endpoints = createAIEndpoints({ registry, sessionManager, diff --git a/packages/ui/components/ProviderIcons.tsx b/packages/ui/components/ProviderIcons.tsx index aaf7503ba..090300cf8 100644 --- a/packages/ui/components/ProviderIcons.tsx +++ b/packages/ui/components/ProviderIcons.tsx @@ -30,6 +30,16 @@ export const OpenCodeIcon: React.FC<{ className?: string }> = ({ className = 'w- ); +/** OrcaRouter icon — a stylized routing node mark */ +export const OrcaRouterIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => ( + + + + + + +); + /** Generic fallback icon for unknown providers */ const GenericProviderIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => ( @@ -43,6 +53,7 @@ export const PROVIDER_META: Record