Skip to content
Draft
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
4 changes: 4 additions & 0 deletions changes/211.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**Other apps can chat with Sentient through an OpenAI-compatible API.** An editor, script or shortcut that speaks
the OpenAI API can get Sentient's replies, with its memory and tools, in a chat of its own. Anything that needs
your OK still asks you in Sentient (and on your phone when you're away), and the app hears plainly when something
wasn't done. It is off until you turn it on, and each app needs Sentient's API key.
5 changes: 3 additions & 2 deletions desktop/src/features/channels/meta.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconDeviceMobile, IconEyeglass2, IconMicrophone, IconMessageCircle, type Icon } from '@tabler/icons-react'
import { IconApi, IconDeviceMobile, IconEyeglass2, IconMicrophone, IconMessageCircle, type Icon } from '@tabler/icons-react'
import { siDiscord, siTelegram, siWhatsapp, type SimpleIcon } from 'simple-icons'
import { Tooltip } from '@/components/ui'
import { cn } from '@/lib/utils'
Expand All @@ -18,7 +18,8 @@ export const CHANNEL_INFO: Record<string, ChannelInfo> = {
whatsapp: { label: 'WhatsApp', from: 'From WhatsApp', mark: { kind: 'si', icon: siWhatsapp } },
voice: { label: 'Voice', from: 'A voice conversation', mark: { kind: 'tabler', icon: IconMicrophone } },
glasses: { label: 'Glasses', from: 'From your glasses', mark: { kind: 'tabler', icon: IconEyeglass2 } },
phone: { label: 'Phone', from: 'From your phone', mark: { kind: 'tabler', icon: IconDeviceMobile } }
phone: { label: 'Phone', from: 'From your phone', mark: { kind: 'tabler', icon: IconDeviceMobile } },
api: { label: 'Another app', from: 'From another app (API)', mark: { kind: 'tabler', icon: IconApi } }
}

export function channelInfo(channel: string | null | undefined): ChannelInfo | null {
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ export interface GatewayConfig {
port: number
}

/** The OpenAI-compatible chat API (#211, docs/API.md section 2). */
export interface OpenAIApiConfig {
enabled: boolean
}

export interface ChatConfig {
history_window: number
compress_after_messages: number
Expand Down Expand Up @@ -454,6 +459,7 @@ export interface SentientConfig {
assistant: AssistantConfig
models: ModelsConfig
gateway: GatewayConfig
openai_api: OpenAIApiConfig
chat: ChatConfig
memory: MemoryConfig
tools: ToolsConfig
Expand Down
36 changes: 36 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,38 @@ accept or loosen a rule.
ends, so calls already running when the budget is reached (a swarm's workers, a chat and a task at once) still
finish, and spending can go past the budget by what they cost.

### OpenAI-compatible API (#211)
Lets other apps on this computer (editors, scripts, shortcuts) chat with Sentient, with its memory and tools, using
any OpenAI client pointed at `<engine>/v1`. Off until `openai_api.enabled` is `true` (`PATCH /api/config`).
- `POST /api/openai-api/key` (window token) → `{key, base_url}`: a new key (`sk-sentient-...`) in the keychain entry
`openai_api_key`, replacing the old one. This answer is the only place it is shown. 503 when the keychain can't
save it.
- `/v1/...` takes `Authorization: Bearer <key>`, never the window's token. Errors use OpenAI's shape
`{error: {message, type, param, code}}`: 404 `api_off` while the setting is off, 401 `invalid_api_key` for a missing
or wrong key, 400 `no_user_message`, 500 `turn_failed` when the turn ended in an error.
- `GET /v1/models` → `{object: "list", data: [{id: "sentient", object: "model", created, owned_by}]}`.
- `POST /v1/chat/completions` `{model, messages: [{role, content}], stream?, user?}` runs one chat turn in the named
chat for `user` (`"API"` when absent, made on first use, `channel: "api"`, title = the name; the mapping is the
`openai_api.sessions` meta entry). Only the last `user` message is sent (its text parts when `content` is a list):
Sentient keeps the chat's history itself, so earlier messages, `system` messages and fields such as `temperature`
are ignored. Without `stream` → a `chat.completion` with the reply and `usage`; with `stream: true` →
`text/event-stream` of `chat.completion.chunk`s (a first `delta: {role: "assistant"}`, `delta.content` per
`text_delta`, a last `delta: {}` with `finish_reason: "stop"`), a `data: {error}` line if the turn failed, then
`data: [DONE]`. API turns have no undo window (`channel: "api"` is not attended, section 10).
- **Approvals still ask in the app.** A call that needs the user's OK (section 2, rules and modes as in any chat)
posts a `kind: "approval"` notification whose `payload` is the turn's `approval_request` (section 1) plus
`session_id` and `source: "api"`: the window shows it with Allow and Deny (answered with `POST /api/approvals`), and
paired chats get it like a task's approval (section 14). The request keeps waiting meanwhile. Once answered,
`payload.status` is `allow` or `deny`; `expired` when nobody answered within `tools.approvals.timeout_s` or the turn
was stopped (`notification.updated`). A call that was declined or not answered in time is never claimed: the reply
(or a last `delta.content`) ends with `Not done: "<tool>" needed your OK in Sentient and didn't get it.`
- **Stop everything** (`POST /api/stop-all`) ends a running API turn, waiting approvals included: 409 `stopped`
(`Stopped.`), or a `data: {error: {message: "Stopped."}}` line before `[DONE]` when streaming. A client that hangs
up doesn't end the turn: it runs to the end and is saved, as in the window.
- Errors carry `x-should-retry: false`, so OpenAI's SDKs don't retry a failed turn and run its tools again.
- The chat list marks these chats "From another app (API)", and Activity says
`From another app (API), in the chat "<name>"` for what they did.

---

## 3. Models & secrets (fully changeable model configuration)
Expand Down Expand Up @@ -2886,6 +2918,10 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a
that delivered it to that chat (`channel_questions`); `tq:` buttons never answer an approval. A reply with a word or
an option number answers it like any task question. Unpaired chats are refused as always. A request past its time
can't be approved (the toast and the message say it expired).
- Approval requests of chat turns started without a window (the OpenAI-compatible API, section 2, #211) arrive as
`kind: "approval"` notifications and are held and sent the same way, as the channel's own approval message: one
button per offered answer (`ap:<code>:<approval_id>`, the same buttons as a chat's approval), answering through
`app.approvals`. When the request is answered anywhere, expires or is stopped, the message's buttons settle.
- Delivery (chats with `deliver: true`, from `notification.new`; a task's `deliver_to` can keep its notifications on
the desktop or send them only to chosen chats instead, section 4 "Where results go"): task results and failures
(`payload.event` in `run_completed`, `run_failed`, `planning_failed`, `clarification_needed`, `disabled`,
Expand Down
2 changes: 2 additions & 0 deletions sentient/activity/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ def _where_chat(self, channel: str | None, title: str | None) -> str:
if channel == "voice":
return "You asked by voice"
name = (title or "").strip()
if channel == "api": # the OpenAI-compatible API (#211): another app asked, not the user
return f'From another app (API), in the chat "{words.short(name, 60)}"' if name else "From another app (API)"
return f'You asked in the chat "{words.short(name, 60)}"' if name else "You asked in a chat"

def _chat_item(self, row: Any) -> Item:
Expand Down
36 changes: 32 additions & 4 deletions sentient/channels/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from sentient.channels.store import ChannelStore
from sentient.channels.telegram import TelegramChannel
from sentient.channels.whatsapp import WhatsAppChannel
from sentient.llm.events import ApprovalRequest
from sentient.memory.forget import forget as forget_learned
from sentient.proactivity.brief import DailyBrief
from sentient.services import Service
Expand All @@ -51,7 +52,7 @@
}
STATUS_LABELS = {
"approved": "Approved", "declined": "Declined", "dismissed": "Dismissed", "answered": "Answered", "cancelled": "Cancelled",
"expired": "Expired",
"expired": "Expired", "allow": "Allowed", "deny": "Denied",
"kept": "Keeping them running", "paused": "Paused", "resumed": "Resumed", # routines while away (#163)
}
APPROVAL_HINT = "Tap a button, or reply to this message with {words}."
Expand Down Expand Up @@ -460,9 +461,34 @@ async def format_notification(self, note: dict, *, chosen: bool = False) -> tupl

@staticmethod
def is_approval(note: dict) -> bool:
"""A task's approval request (#139, ADR 0018), as opposed to an ordinary question."""
"""A task's approval request (#139, ADR 0018), as opposed to an ordinary question, or a live one."""
payload = note.get("payload") or {}
return note.get("kind") == "task" and payload.get("event") == "question" and isinstance(payload.get("approval"), dict)
task = note.get("kind") == "task" and payload.get("event") == "question" and isinstance(payload.get("approval"), dict)
return task or ChannelService.is_live_approval(note)

@staticmethod
def is_live_approval(note: dict) -> bool:
"""An approval a chat turn without a window is waiting on (an API turn, #211): ``payload`` is its
``approval_request``, answered through ``app.approvals`` like a chat's."""
return note.get("kind") == "approval" and bool((note.get("payload") or {}).get("approval_id"))

async def _deliver_live_approval(self, note: dict) -> int:
"""The channel's own Allow and Deny message for a live approval, whose buttons answer it."""
payload = note.get("payload") or {}
if payload.get("status") or payload.get("approval_id") not in self.app.approvals.pending_ids():
return 0
event = ApprovalRequest.model_validate(payload)
sent = 0
for ch, chat in await self._delivery_targets():
# ponytail: a channel answers from one chat per request (the last one sent to); fine with one paired chat
mid = await ch.send_approval(chat["chat_id"], event)
if mid is None:
continue
sent += 1
self._delivered.setdefault(str(note["id"]), []).append((ch.id, chat["chat_id"], mid))
await self.record_delivery(ch, chat, "**Approval needed**", what="approval", reason="default",
ref=self._source_ref(note))
return sent

@staticmethod
def _format_approval(note: dict) -> tuple[str, list[list[Button]] | None] | None:
Expand Down Expand Up @@ -510,7 +536,7 @@ async def deliver_held(self) -> int:
for nid, note in list(self._held.items()):
self._held.pop(nid, None)
run_id = str((note.get("payload") or {}).get("run_id") or "")
if run_id and await self._waiting_question(run_id) is not None:
if self.is_live_approval(note) or (run_id and await self._waiting_question(run_id) is not None):
sent += await self.deliver_notification(note)
return sent

Expand Down Expand Up @@ -575,6 +601,8 @@ async def deliver_notification(self, note: dict) -> int:
if note.get("id") and not (note.get("payload") or {}).get("status"):
self._held[str(note["id"])] = note # the person is looking at the app: ask there first (#139)
return 0
if self.is_live_approval(note):
return await self._deliver_live_approval(note)
if note.get("kind") == "reminder": # a timer or reminder goes back to the chat it was set in (#150)
where = await self._reminder_delivery(note)
else:
Expand Down
9 changes: 9 additions & 0 deletions sentient/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,14 @@ class GatewayConfig(BaseModel):
port: int = Field(7777, ge=0, le=65535, description="0 lets the desktop app pick a free port.")


class OpenAIApiConfig(BaseModel):
enabled: bool = Field(
False,
description="Let apps on this computer (editors, scripts, shortcuts) chat with Sentient, with its memory and "
"tools, through an OpenAI-compatible API at /v1/chat/completions. Each app needs Sentient's API key.",
)


class ChatConfig(BaseModel):
history_window: int = Field(30, ge=2, description="Recent messages sent to the model per turn.")
compress_after_messages: int = Field(
Expand Down Expand Up @@ -1324,6 +1332,7 @@ class SentientConfig(BaseModel):
assistant: AssistantConfig = Field(default_factory=AssistantConfig)
models: ModelsConfig = Field(default_factory=ModelsConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
openai_api: OpenAIApiConfig = Field(default_factory=OpenAIApiConfig)
chat: ChatConfig = Field(default_factory=ChatConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig)
Expand Down
3 changes: 3 additions & 0 deletions sentient/gateway/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
timeline : core (the activity timeline)
folders : folders package (folders and Obsidian vaults as notes)
reminders : reminders package (timers and one-off reminders)
openai_api : core (the OpenAI-compatible chat API, #211)
"""

from sentient.gateway.routes import (
Expand All @@ -31,6 +32,7 @@
models,
nodes,
notifications,
openai_api,
proactivity,
reminders,
sandbox,
Expand Down Expand Up @@ -66,4 +68,5 @@
timeline.router,
folders.router,
reminders.router,
openai_api.router,
]
Loading
Loading