Files
CherryHQ-cherry-studio/docs/references/api-gateway/README.md
fullex 87b2b64ba5 refactor(process-entry): rename main/preload entries to named files per naming §6.4
src/main/index.ts → main.ts and src/preload/index.ts → preload.ts, so the process entries stop occupying the barrel-reserved index filename (§6.4) without needing an exemption list. The rename is wired through electron.vite (main build.lib.entry — rollupOptions.input would bypass electron-vite's output-format detection — and the preload input key), package.json main (out/main/main.js), WindowManager's default preload filename, MainWindowService's webview preload path, and DbService's slow-query stack filter, which matches the bundled artifact name. preload.d.ts becomes globals.d.ts: sharing preload.ts's basename makes TypeScript drop the .d.ts as that file's presumed output declaration, and tsgo only applies its global Window augmentation when the declaration sorts before its importer, so the file keeps a distinct, alphabetically-early name. Entry-path mentions in code comments and reference docs follow.
2026-07-04 20:18:14 -07:00

312 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Gateway Reference
The **API Gateway** exposes Cherry Studio's AI capabilities over a local HTTP
server that speaks the **OpenAI** and **Anthropic** wire protocols, plus a few
Cherry-specific REST endpoints (models, knowledge bases). Any OpenAI- or
Anthropic-compatible client (SDKs, Claude Code, `curl`, …) can point at
`http://127.0.0.1:23333` and drive whatever provider/model the desktop app has
configured — Cherry becomes a universal translation gateway in front of every
provider it knows.
Internally each request is routed through main's `AiStreamManager` as an equal,
**non-persisting** subscriber (alongside the renderer's `WebContentsListener`
and the IM `ChannelAdapterListener`), and the resulting `UIMessageChunk` stream
is translated back into the caller's dialect by the adapter system.
> **Naming.** The code, IPC, preload, hook, and UI all use the
> **`apiGateway`** name. The persisted **preference / shared-cache** namespace
> is **`feature.api_gateway.*`** — same feature, two names. (`api_gateway` is the
> current namespace token; it replaced the retired `csaas` alias.) The legacy v1
> Redux layer (`apiServer.*`) is deprecated and reaches v2 only through the
> migrators; do not add fallbacks for it.
## Where the code lives
```
src/main/features/apiGateway/ ← the HTTP server (Elysia + @elysia/node)
├── server.ts ← `ApiGateway` class: listen / stop, http timeouts
├── app.ts ← `buildApp()`: CORS, OpenAPI docs, request-id, error handler, route mounting
├── errors.ts ← `gatewayErrorHandler` (path → anthropic/openai/rest envelopes),
│ `buildStreamErrorFrame` (streaming error/timeout frames), `transformOpenAiError`
├── ApiGatewayService.ts ← lifecycle owner (start/stop, IPC, auto-start, running-state)
├── proxyStream.ts ← `processMessage()` — the core request → stream → response engine
├── reasoningCache.ts ← google / openrouter reasoning-signature caches
├── middleware/
│ └── auth.ts ← `authorizeApiRequest` (x-api-key | Bearer, timing-safe)
├── routes/
│ ├── messages.ts ← POST /v1/messages, POST /v1/messages/count_tokens (Anthropic)
│ ├── chat.ts ← POST /v1/chat/completions (OpenAI Chat)
│ ├── responses.ts ← POST /v1/responses (OpenAI Responses)
│ ├── models.ts ← GET /v1/models
│ ├── knowledge/ ← GET/POST /v1/knowledge-bases[/search|/:id]
│ └── schemas.ts ← loose Zod body schemas (validate only what the gateway needs)
├── utils/
│ └── models.ts ← `getModels()` — the /v1/models data path (never throws)
└── adapters/
├── interfaces.ts ← `IMessageConverter` / `IStreamAdapter` / `ISseFormatter` contracts
├── converters/ ← input dialect → AI SDK `UIMessage[]` + tools + options
├── stream/ ← `UIMessageChunk` → output dialect events (push API)
├── formatters/ ← output event → SSE wire string
└── factory/ ← `MessageConverterFactory`, `StreamAdapterFactory`
src/preload/preload.ts ← `window.api.apiGateway.{start,stop,restart}`
src/renderer/hooks/useApiGateway.ts ← renderer state (config + running + loading) and actions
src/renderer/pages/settings/ToolSettings/ApiGatewaySettings/ ← settings UI
```
## HTTP surface
`buildApp()` (`app.ts`) assembles one Elysia app on the `@elysia/node` adapter.
CORS is open (`origin: true`); every request is stamped with an `X-Request-ID`
and its latency logged on completion.
### Public (no auth)
| Method & path | Purpose |
|---|---|
| `GET /` | API information (name, version, endpoint map) |
| `GET /health` | Health check (`{ status, timestamp, version }`) |
| `GET /openapi` | Scalar API docs UI (front-end assets load from a CDN — see note) |
| `GET /openapi/json` | OpenAPI JSON spec (fully local) |
> **Offline note.** `@elysia/openapi`'s Scalar provider serves an HTML page that
> pulls its UI bundle from a CDN, so `GET /openapi` (the human docs UI) needs
> network. `GET /openapi/json` — the machine-readable spec that programmatic
> clients/SDKs consume — is always served locally and is unaffected. To make the
> UI work fully offline, pass a self-hosted `cdn` URL to the `openapi({...})` config
> in `app.ts`.
### Protected (`/v1`, requires API key)
Mounted under a single `Elysia({ prefix: '/v1' })` that `.use(bearer())` and
applies a **`scoped`** auth guard — so the guard covers every `/v1` plugin but
none of the public routes above.
| Method & path | Dialect | In → out format |
|---|---|---|
| `POST /v1/messages` | Anthropic | `anthropic``anthropic` |
| `POST /v1/messages/count_tokens` | Anthropic | local token estimate (`tokenx`), no stream |
| `POST /v1/chat/completions` | OpenAI Chat | `openai``openai` |
| `POST /v1/responses` | OpenAI Responses | `openai-responses``openai-responses` |
| `GET /v1/models` | OpenAI list | `{ object:'list', data:[…] }`, ids are `providerId:modelId` (offset/limit) |
| `GET /v1/knowledge-bases` | Cherry REST | list (offset/limit) |
| `POST /v1/knowledge-bases/search` | Cherry REST | semantic search across bases |
| `GET /v1/knowledge-bases/:id` | Cherry REST | single base |
The model in every chat/messages/responses body is `"<providerId>:<modelId>"`
(split on the **first** `:`), e.g. `anthropic:claude-sonnet-4-6`.
## Request flow (chat / messages / responses)
All three streaming endpoints are thin route wrappers that call
`processMessage({ params, inputFormat, outputFormat, signal })` in
`proxyStream.ts`. That function is the heart of the gateway:
1. **Resolve model.** Read `params.model`, split on the first `:` into
`providerId` / `modelId`, build a `uniqueModelId` via `createUniqueModelId`.
`params.stream === true` selects streaming vs. JSON.
2. **Convert input.** `MessageConverterFactory.create(inputFormat, …)` returns
the dialect's `IMessageConverter`, which yields:
- `toUIMessages(params)` → AI SDK `UIMessage[]` (a system/instructions
prompt becomes a leading `role: 'system'` message).
- `toAiSdkTools(params)` → a `ToolSet` of **client tools** (no `execute`):
the model emits the call and the gateway forwards it to the caller.
- `extractStreamOptions(params)` → sampling (`temperature`, `topP`,
`topK`, `maxOutputTokens`, `stopSequences`).
- `extractProviderOptions(provider, params)` → reasoning/thinking options
(the `Provider` is loaded best-effort from `ProviderService`).
3. **Assemble overrides.** Sampling + tools + provider options are merged into a
single `CallOverrides` object — the gateway is **assistant-agnostic**, so
everything is passed per-request (merged at highest precedence inside
`buildAgentParams`).
4. **Pick the output adapter.** `StreamAdapterFactory.createAdapter(outputFormat)`
+ `.getFormatter(outputFormat)` give the `IStreamAdapter` (state machine that
turns `UIMessageChunk`s into dialect events) and the `ISseFormatter` (event →
SSE string).
5. **Drive the stream.** With `streamId = "gateway-<uuid>"`, call
`AiStreamManager.streamPrompt({ streamId, uniqueModelId, messages, listener,
callOverrides, idleTimeoutMs })`. This uses the **`promptStreamLifecycle`** —
no status broadcast, no attach/reconnect, no persistence; the stream evicts
immediately at terminal.
- **Streaming**: an `SseListener` with a push-API `formatChunk` /
`formatDone` / `formatPaused` / `formatError` pipes the adapter's events
through the formatter into a `text/event-stream` `ReadableStream`.
- **Non-streaming**: a plain `StreamListener` feeds every chunk into the
adapter to accumulate state, then `adapter.buildNonStreamingResponse()` is
returned as a JSON `Response`.
6. **Abort & timeout.** The route's `request.signal` (client disconnect) calls
`aiStreamManager.abort(streamId, …)`. An idle (no-chunk) timeout —
**20 minutes** (`GATEWAY_STREAM_IDLE_TIMEOUT_MS`) — and any mid-stream abort
surface as a **failure**, not a truncated success: streaming emits a
per-dialect error frame (`buildStreamErrorFrame`), non-streaming returns a
**504**. The server's per-request timeout is **5 minutes** (`server.ts`), with
`setTimeout(0)` so live SSE connections are not socket-timed-out.
```
client ──HTTP──▶ route ──▶ processMessage
│ converter (in dialect → UIMessage[] + tools + overrides)
AiStreamManager.streamPrompt (equal, non-persisting subscriber)
│ UIMessageChunk stream
IStreamAdapter.transformChunk → ISseFormatter.formatEvent
SSE ReadableStream / JSON Response ──▶ client
```
## Adapter system
Two independent dialect axes, chosen by `inputFormat` / `outputFormat`:
| Role | Interface | Implementations |
|---|---|---|
| **Converter** (input → AI SDK) | `IMessageConverter` | `anthropic`, `openai`, `openai-responses` |
| **Stream adapter** (`UIMessageChunk` → events) | `IStreamAdapter` | `AiSdkToAnthropicSse`, `AiSdkToOpenAiSse`, `AiSdkToOpenAiResponsesSse` |
| **Formatter** (event → SSE string) | `ISseFormatter` | `AnthropicSseFormatter`, `OpenAiSseFormatter`, `OpenAiResponsesSseFormatter` |
The output formats are **`anthropic`**, **`openai`**, and **`openai-responses`**
— the full `OutputFormat` union, each registered in `StreamAdapterFactory`.
Adapters consume the AI SDK **`UIMessageChunk`** stream (not `fullStream`):
- **Usage** comes from `message-metadata` chunks, projected as
`GatewayUsageMetadata` (`promptTokens` = input, `completionTokens` = output,
`thoughtsTokens` = reasoning, `totalTokens`). There is **no cache-token
breakdown** on this channel.
- **`finishReason`** comes from the `finish` chunk; reasoning **signatures**
come from the reasoning part's `providerMetadata` (cached per provider via
`reasoningCache.ts` so split signatures survive across chunks).
## Lifecycle & configuration
### `ApiGatewayService` (`src/main/features/apiGateway/ApiGatewayService.ts`)
A `BaseService``@Injectable('ApiGatewayService')`,
`@ServicePhase(Phase.WhenReady)`, implements **`Activatable`** — registered one
line in `src/main/core/application/serviceRegistry.ts`. It owns the `ApiGateway`
HTTP server (`src/main/features/apiGateway`) and is the single authority for
running state.
| Hook | Responsibility |
|---|---|
| `onInit` | Register the start/stop/restart IPC handlers; subscribe to `feature.api_gateway.enabled` changes (toggling the preference activates/deactivates the gateway). |
| `onReady` | `shouldAutoStart()` → activate if `feature.api_gateway.enabled` **or** at least one agent exists. |
| `onActivate` | `ensureValidApiKey()``new ApiGateway()``start()` → publish `running = true`. On failure, tears down partial state and republishes `false`. |
| `onDeactivate` | `stop()` the server, publish `running = false`. |
`ensureValidApiKey()` generates a `cs-sk-<uuid>` key into
`feature.api_gateway.api_key` the first time it is missing.
All activation/deactivation flows through a self-held
[`createLatestReconciler`](../../../src/main/core/concurrency/README.md) — the
**sole** caller of `activate`/`deactivate`, driven by `onReady`, the
`feature.api_gateway.enabled` subscription, and the IPC `start`/`stop`/`restart`
(which set `desiredEnabled`, then `request()` + `await flush()`). It converges the
running state to the latest desired value (`getSnapshot` re-reads the actual
activated state every pass), so an opposing toggle that lands mid-transition can't
leave the gateway diverged from intent; a still-current failure isn't retried (no
spin). `restart()` is `stop()` then `start()`.
### Running state — Shared Cache, not IPC
`publishRunningState()` writes `feature.api_gateway.running` (boolean) into the
**Shared Cache** via `CacheService.setShared(...)`. **Main is authoritative**;
the renderer reads it reactively with `useSharedCache('feature.api_gateway.running')`.
There is deliberately **no status/config pull IPC** — pulling running state or
config over IPC would be an anti-pattern, since running lives in the shared
cache and config lives in the preference (DataApi) layer.
### IPC (imperative actions only)
| Channel (`IpcChannel`) | Value | Handler |
|---|---|---|
| `ApiGateway_Start` | `api-gateway:start` | `start()``{ success } \| { success:false, error }` |
| `ApiGateway_Stop` | `api-gateway:stop` | `stop()` |
| `ApiGateway_Restart` | `api-gateway:restart` | `restart()` (stop → start) |
Preload exposes these as `window.api.apiGateway.{start,stop,restart}`.
### Preferences (`feature.api_gateway.*`)
| Key | Type | Default | Notes |
|---|---|---|---|
| `feature.api_gateway.enabled` | `boolean` | `false` | Auto-start on launch / toggled from settings |
| `feature.api_gateway.host` | `string` | `'127.0.0.1'` | Bind address |
| `feature.api_gateway.port` | `number` | `23333` | TCP port (UI clamps 100065535) |
| `feature.api_gateway.api_key` | `string \| null` | `null` | Auto-generated `cs-sk-<uuid>` on first activate |
Migrated from v1 `redux/settings/apiServer.{enabled,host,port,apiKey}` via the
v2 preference migrators. Edit `classification.json` (not the generated schemas)
to change these — see the v2 data-classify toolchain.
### Renderer
`useApiGateway()` reads config (`enabled`/`host`/`port`/`apiKey`) from
preferences and `running` from the shared cache, exposes `loading`, and wraps
the three IPC actions plus `setApiGatewayEnabled` / `setApiGatewayConfig`. The
`ApiGatewaySettings` page renders the status indicator, start/stop/restart
controls, port input, server URL, the (copy/regenerate) API key, an
`Authorization` header example, and a link to `…/openapi`. All strings live
under the `apiGateway` i18n namespace.
## Authentication
`authorizeApiRequest(xApiKey, bearerToken)` (`middleware/auth.ts`), run from the
`/v1` guard's `beforeHandle`:
1. Token = trimmed `x-api-key` header (Anthropic style, takes priority) **or**
`Authorization: Bearer <token>` (OpenAI style, parsed by `@elysia/bearer`).
2. No token → **401** `Unauthorized: missing credentials`.
3. No `feature.api_gateway.api_key` configured → **403** `Forbidden`.
4. Compare against the configured key with **`crypto.timingSafeEqual`**
(length-checked first). Match → allow; mismatch → **403** `Forbidden`.
## Error handling
One root `onError` (`gatewayErrorHandler`) selects the response envelope by
request **path**, so every endpoint speaks its caller's dialect:
| Path prefix | Envelope | Builder |
|---|---|---|
| `/v1/messages` | Anthropic `{ type:'error', error:{ type, message } }` | `anthropicErrorHandler` |
| `/v1/chat`, `/v1/responses` | OpenAI `{ error:{ message, type, code } }` | `openaiErrorHandler` |
| everything else | Cherry REST `{ error:{ code, message, details? } }` | `restErrorHandler` |
`DataApiError`s (from the data-layer services backing models/knowledge) carry
their own `status`/`code` and are mapped straight into the selected envelope.
Built-in Elysia `VALIDATION` / `NOT_FOUND` / `PARSE` codes map to 400/404/400
(422 for REST validation). Unknown provider/runtime errors are shaped by
`transformAnthropicError` / `transformOpenAiError`**status-driven**: they read
`statusCode` off the AI-SDK `SerializedError`, so a provider 401/429/… keeps its
real status and message instead of flattening to 500. Internal-error messages are
gated behind `isDev`, and the AI-SDK error extras (`stack` / `url` /
request+response bodies) are dropped — for both the JSON handlers and the
streaming `buildStreamErrorFrame`.
## Key invariants
- **Equal, non-persisting subscriber.** The gateway uses
`promptStreamLifecycle` — its turns are not persisted, not broadcast as topic
status, and not attachable. It shares the exact same `AiStreamManager` engine
as the renderer and IM channels; nothing special-cases it upstream.
- **Assistant-agnostic.** No assistant/topic context. Sampling, client tools,
and provider options ride as per-request `CallOverrides`.
- **Main owns running state.** `feature.api_gateway.running` in the Shared Cache is
the one source of truth; the renderer mirrors it, never sets it.
- **Dialect is chosen by path, both directions.** Input format is fixed per
route; output envelope (success and error) is chosen from the path, so a
client always gets back the protocol it spoke.
- **Auth key is the persisted preference.** `feature.api_gateway.api_key`, compared
timing-safe; auto-generated on first activation.
## Related references
- [AI Reference](../ai/README.md) — `AiStreamManager`, `streamPrompt`,
`UIMessageChunk`, `buildAgentParams` / `CallOverrides`, the listener model
(`SseListener`, `WebContentsListener`).
- [Service Lifecycle](../lifecycle/README.md) — `BaseService`, `Activatable`,
`@ServicePhase`, `serviceRegistry.ts`.
- [Data Layer](../data/README.md) — Preference (`feature.api_gateway.*`) and Cache
(`feature.api_gateway.running`) systems; `ProviderService`, `KnowledgeBaseService`.
```