Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
21 changes: 20 additions & 1 deletion apps/marketing/src/content/docs/guides/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type {
CodexSDKConfig,
PiSDKConfig,
OpenCodeConfig,
OrcaRouterConfig,
} from "./types.ts";

// Provider registry
Expand Down
1 change: 1 addition & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
82 changes: 82 additions & 0 deletions packages/ai/providers/orcarouter.test.ts
Original file line number Diff line number Diff line change
@@ -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" } }]);
});
});
Loading