diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3ef3b..e1e8d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ to an operator decision instead of a flat hard-deny. ### Added +- **Two-tier runtime model.** Agents declare `runtime` (`direct` = vendor + runtime on a subscription, `claude` today; `api` = ritsu's own tool loop on + a metered key) + `provider`. Existing databases migrate automatically. +- **api providers:** `anthropic` / `openai` / `gemini` via their official + SDKs, `xai` (Grok), `openrouter`, `litellm` (key optional), `custom` + base_url. Tool calls stay in ritsu's loop — the approval gate applies + unchanged. Sampling params are sent only when set in `provider_options`. +- **Health tab (System).** Live checks: core runtime, one free probe per + stored provider key, and every configured connector. - **Blocked sub-tab (Approvals → Blocked).** Lists recent inter-agent call denials — caller → target, reason, detail, attempted message, age — live over the approvals SSE stream. Escalation denials are visually flagged as diff --git a/README.md b/README.md index a9685e1..75ceb3f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ What's baked in: - **Per-agent isolation enforced *before* tools fire.** `tools_allowlist` + per-path workspace permissions go through the SDK's `canUseTool` callback. An agent with no `Bash` and no writable workspace can't exfiltrate files even if perfectly socially-engineered. - **AES-256-GCM secrets at rest.** Bot tokens, API keys; master key separable from the DB dir. - **Strict CSP, audit log, OAuth 2.1 + DCR + PKCE + RFC 8707** — full posture in [`docs/threat-model.md`](./docs/threat-model.md). -- **Two runtimes.** `claude-sdk` (Max plan via `@anthropic-ai/claude-agent-sdk`, $0 per turn) or `ritsu-agent` (your API key against any OpenAI-compatible provider — OpenAI, OpenRouter, local LiteLLM, etc.). Same tools, same memory, same UI. +- **Two runtime tiers.** `direct` rides a vendor agent runtime on a subscription (Claude via `@anthropic-ai/claude-agent-sdk`, $0 per turn; more vendors as their runtimes ship). `api` runs ritsu's own tool loop against a metered key — Anthropic, OpenAI, and Gemini through their official SDKs, Grok via xAI's OpenAI-compatible API, plus OpenRouter, a local LiteLLM proxy, or any custom OpenAI-compatible endpoint. Same tools, same memory, same UI. --- diff --git a/docs/examples/README.md b/docs/examples/README.md index 979fa5b..b1bd6c5 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -19,14 +19,18 @@ Each example below maps to the **Agents → Create / edit** form: | `name` | name | | `description` | description | | `system_prompt` | system prompt (textarea) | -| `dispatcher` | dispatcher | +| `runtime` | runtime (direct / api) | +| `provider` | provider | | `model` | model | | `tools_allowlist` | tools allowlist (checkboxes) | | `can_call` | can call (checkboxes — only enabled agents appear) | -`provider` / `api_key_ref` / `provider_options` are for the ritsu-agent -runtime (your-own-API-key path); leave them at `null` / `{}` for the -default Max-plan claude-sdk dispatcher. +`runtime: "direct"` + `provider: "claude"` is the Max-plan default. +`runtime: "api"` runs ritsu's own loop against a metered provider +(`anthropic` / `openai` / `gemini` / `xai` / `openrouter` / `litellm` / +`custom`) — set `api_key_ref` to a minted key (optional for +litellm/custom) and tune `provider_options` (temperature, max_tokens, +base_url) as needed. ## Pasting via curl diff --git a/docs/examples/code-reader.json b/docs/examples/code-reader.json index ed4c8e6..5738258 100644 --- a/docs/examples/code-reader.json +++ b/docs/examples/code-reader.json @@ -4,13 +4,13 @@ "name": "Code Reader", "description": "Read-only code explorer for a single project workspace. Answers \"where is X defined / used / called from\" questions.", "system_prompt": "You are a read-only code explorer for the workspace you have access to.\n\nWhen the user asks about the code:\n- Use Glob to find candidate files by pattern (`src/**/*.ts`).\n- Use Grep to locate symbol definitions / usages (matches against file contents).\n- Use Read to pull the relevant chunk and answer with file path + line numbers.\n\nWhen citing code: always include the path and line range. Never speculate about file contents you haven't actually read.\n\nYou are READ-ONLY. You do not have Write, Edit, or Bash. If asked to change code, explain that you can only describe the change — the user (or a different agent) has to apply it.\n\nFor durable facts about the project (module layout, naming conventions, recurring patterns), use mcp__memory__remember. Read your memories before answering, so you don't re-derive the same facts every session.", - "dispatcher": "claude-direct", + "runtime": "direct", "model": "claude-sonnet-4-6", "memory_backend": "sqlite", "tools_allowlist": ["Read", "Glob", "Grep"], "can_call": [], "capabilities": [], - "provider": null, + "provider": "claude", "api_key_ref": null, "provider_options": {}, "enabled": true diff --git a/docs/examples/notetaker.json b/docs/examples/notetaker.json index 7a957db..c0b0089 100644 --- a/docs/examples/notetaker.json +++ b/docs/examples/notetaker.json @@ -4,13 +4,13 @@ "name": "Notetaker", "description": "Summarises any pasted text into bullet points + open questions.", "system_prompt": "You are a notetaker. When given any block of text, your job is to:\n1. Extract the 3-5 most important facts as bullet points.\n2. Identify any open questions or unresolved action items as a separate list.\n3. Be brief — no preamble, no recap, just the bullets.\n\nUse mcp__memory__remember to persist durable preferences the user expresses (e.g. \"user prefers markdown bullets\", \"omit the open-questions list when there's only one\"). Read your existing memories at the start of every conversation and apply them.", - "dispatcher": "claude-direct", + "runtime": "direct", "model": "claude-sonnet-4-6", "memory_backend": "sqlite", "tools_allowlist": [], "can_call": [], "capabilities": [], - "provider": null, + "provider": "claude", "api_key_ref": null, "provider_options": {}, "enabled": true diff --git a/docs/examples/research-assistant.json b/docs/examples/research-assistant.json index ed3effb..490af89 100644 --- a/docs/examples/research-assistant.json +++ b/docs/examples/research-assistant.json @@ -4,13 +4,13 @@ "name": "Research Assistant", "description": "Web research agent with persistent memory. Searches via the configured searxng instance, fetches pages, and accumulates findings across sessions.", "system_prompt": "You are a research assistant. Your job is to answer factual questions with concrete, sourced evidence — never from memory of your training data alone when fresh information is available.\n\nWorkflow:\n1. Use WebSearch to find candidate sources for the question.\n2. Use WebFetch to pull the actual page contents of the most promising 2-3 results.\n3. Synthesise an answer that quotes / cites the source URLs.\n4. Save any durable findings via mcp__memory__remember — e.g. \"AWS SQS FIFO queues have a 300 TPS limit per message group (https://...)\".\n\nDo NOT fabricate citations. If you can't find a source, say so. If WebSearch returns nothing useful, fall back to your trained knowledge but mark it clearly: \"(no fresh source; from training data, may be outdated)\".\n\nRead your existing memories at the start of every conversation — the user is building up a research base, and you should reuse prior findings instead of re-deriving them.", - "dispatcher": "claude-direct", + "runtime": "direct", "model": "claude-sonnet-4-6", "memory_backend": "sqlite", "tools_allowlist": ["WebFetch", "WebSearch"], "can_call": [], "capabilities": [], - "provider": null, + "provider": "claude", "api_key_ref": null, "provider_options": {}, "enabled": true diff --git a/docs/getting-started.md b/docs/getting-started.md index 742f71c..26a7377 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -73,7 +73,8 @@ Go to **Agents** → fill out the form: | type | `generic` | | name | `Notetaker` | | description | `Summarises text into bullet points.` | -| dispatcher | `claude-direct` | +| runtime | `direct` | +| provider | `claude` | | model | `claude-sonnet-4-6` | | memory backend | `sqlite` | | system_prompt | (see below) | diff --git a/package-lock.json b/package-lock.json index 40a781c..385f30d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,14 @@ "license": "BUSL-1.1", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.217", + "@anthropic-ai/sdk": "^0.115.0", + "@google/genai": "^2.13.0", "@modelcontextprotocol/sdk": "^1.0.0", "express": "^5.2.1", "imapflow": "^1.4.8", "mailparser": "^3.9.14", "nodemailer": "^9.0.3", + "openai": "^6.49.0", "re2-wasm": "^1.0.2", "twitter-api-v2": "^1.29.0", "undici": "^8.8.0", @@ -168,9 +171,9 @@ ] }, "node_modules/@anthropic-ai/sdk": { - "version": "0.98.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz", - "integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz", + "integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==", "license": "MIT", "peer": true, "dependencies": { @@ -744,6 +747,30 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@google/genai": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.13.0.tgz", + "integrity": "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@hono/node-server": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", @@ -907,6 +934,63 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", @@ -1038,7 +1122,6 @@ "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -1068,6 +1151,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -1371,6 +1460,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1449,6 +1547,35 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1499,6 +1626,12 @@ "node": "20 || >=22" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1664,6 +1797,15 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1775,6 +1917,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2227,6 +2378,12 @@ "express": ">= 4.11" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2287,6 +2444,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2376,6 +2556,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2418,6 +2610,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2509,6 +2729,32 @@ "node": ">=10.13.0" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2651,6 +2897,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -2811,6 +3070,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2850,6 +3118,27 @@ "dev": true, "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2942,6 +3231,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.5.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", @@ -3089,6 +3384,44 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/nodemailer": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", @@ -3149,6 +3482,36 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3199,6 +3562,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parseley": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", @@ -3352,6 +3728,29 @@ ], "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3456,6 +3855,15 @@ "node": ">=0.10.0" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3482,6 +3890,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -3978,7 +4406,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -4024,6 +4451,15 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4073,6 +4509,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 5a983f4..cf65414 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,14 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.217", + "@anthropic-ai/sdk": "^0.115.0", + "@google/genai": "^2.13.0", "@modelcontextprotocol/sdk": "^1.0.0", "express": "^5.2.1", "imapflow": "^1.4.8", "mailparser": "^3.9.14", "nodemailer": "^9.0.3", + "openai": "^6.49.0", "re2-wasm": "^1.0.2", "twitter-api-v2": "^1.29.0", "undici": "^8.8.0", diff --git a/src/__tests__/admin-health.test.ts b/src/__tests__/admin-health.test.ts new file mode 100644 index 0000000..2a6dc8e --- /dev/null +++ b/src/__tests__/admin-health.test.ts @@ -0,0 +1,93 @@ +import { describe, it, beforeEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { randomBytes } from 'node:crypto'; +import { openDatabase } from '../db.js'; +import { ApiKeyStore } from '../auth/api-key-store.js'; +import { _resetKeyCacheForTests } from '../util/secret-crypto.js'; +import { runHealthChecks, type HealthDeps } from '../admin/health.js'; + +function fakeSecrets(values: Record): HealthDeps['secrets'] { + return { get: (ns, name) => values[`${ns}:${name}`] ?? null }; +} + +const okDefStore: HealthDeps['defStore'] = { list: async () => [] }; + +/** fetch stub: records URLs + auth headers, answers per-URL status. */ +function fakeFetch(seen: Array<{ url: string; auth: string | null }>, statusFor: (url: string) => number): typeof fetch { + return async (url, init) => { + const u = url instanceof Request ? url.url : String(url); + const h = new Headers(init?.headers); + seen.push({ url: u, auth: h.get('authorization') ?? h.get('x-api-key') }); + return new Response('{}', { status: statusFor(u), headers: { 'content-type': 'application/json' } }); + }; +} + +describe('runHealthChecks (System → Health)', () => { + let apiKeys: ApiKeyStore; + + beforeEach(() => { + process.env.RITSU_MASTER_KEY = randomBytes(32).toString('base64'); + _resetKeyCacheForTests(); + apiKeys = new ApiKeyStore(openDatabase(':memory:')); + }); + + it('probes each active key against its provider model-list endpoint', async () => { + apiKeys.mint('ant-key', 'anthropic', 'sk-ant-x'); + apiKeys.mint('xai-key', 'xai', 'xai-x'); + apiKeys.mint('custom-key', 'custom', 'whatever'); + const seen: Array<{ url: string; auth: string | null }> = []; + const { checks } = await runHealthChecks({ + defStore: okDefStore, apiKeys, secrets: fakeSecrets({}), + fetchImpl: fakeFetch(seen, () => 200), claudeCredsPath: '/nonexistent', + }); + + assert.ok(seen.some(s => s.url === 'https://api.anthropic.com/v1/models' && s.auth === 'sk-ant-x')); + assert.ok(seen.some(s => s.url === 'https://api.x.ai/v1/models' && s.auth === 'Bearer xai-x')); + const custom = checks.find(c => c.label.includes('custom'))!; + assert.equal(custom.status, 'skip'); + assert.equal(checks.filter(c => c.group === 'providers' && c.status === 'ok').length, 2); + }); + + it('maps auth rejection and unreachability to fail with detail', async () => { + apiKeys.mint('bad-key', 'openai', 'sk-bad'); + const { checks } = await runHealthChecks({ + defStore: okDefStore, apiKeys, + secrets: fakeSecrets({ 'flashback:url': 'http://localhost:9', 'flashback:token': 't' }), + fetchImpl: async (url: string | URL | Request) => { + const u = url instanceof Request ? url.url : String(url); + if (u.includes('localhost:9')) throw new Error('connect ECONNREFUSED'); + return new Response('{}', { status: 401 }); + }, + claudeCredsPath: '/nonexistent', + }); + assert.equal(checks.find(c => c.id.startsWith('key-'))?.status, 'fail'); + assert.equal(checks.find(c => c.id.startsWith('key-'))?.detail, 'HTTP 401'); + const fb = checks.find(c => c.id === 'flashback')!; + assert.equal(fb.status, 'fail'); + assert.match(fb.detail ?? '', /ECONNREFUSED/); + }); + + it('reports unconfigured connectors as skip and partial config as fail', async () => { + const { checks } = await runHealthChecks({ + defStore: okDefStore, apiKeys, + secrets: fakeSecrets({ 'email:imap_host': 'mail.example.com' }), + fetchImpl: fakeFetch([], () => 200), claudeCredsPath: '/nonexistent', + }); + assert.equal(checks.find(c => c.id === 'twitter')?.status, 'skip'); + assert.equal(checks.find(c => c.id === 'flashback')?.status, 'skip'); + const email = checks.find(c => c.id === 'email')!; + assert.equal(email.status, 'fail'); + assert.match(email.detail ?? '', /partially configured/); + assert.equal(checks.find(c => c.id === 'claude-session')?.status, 'fail'); + }); + + it('appends /v1/models to a litellm url that lacks the /v1 suffix', async () => { + const seen: Array<{ url: string; auth: string | null }> = []; + await runHealthChecks({ + defStore: okDefStore, apiKeys, + secrets: fakeSecrets({ 'litellm:url': 'http://localhost:4000' }), + fetchImpl: fakeFetch(seen, () => 200), claudeCredsPath: '/nonexistent', + }); + assert.ok(seen.some(s => s.url === 'http://localhost:4000/v1/models')); + }); +}); diff --git a/src/__tests__/agent-capabilities.test.ts b/src/__tests__/agent-capabilities.test.ts index 469f04e..3b7fd71 100644 --- a/src/__tests__/agent-capabilities.test.ts +++ b/src/__tests__/agent-capabilities.test.ts @@ -19,12 +19,12 @@ function def(overrides: Partial = {}): AgentDefinition { name: 'A', description: 'a', system_prompt: 'be brief', - dispatcher: 'claude-direct', + runtime: 'direct', model: 'claude-sonnet-4-6', memory_backend: 'sqlite', tools_allowlist: [], can_call: [], - provider: null, + provider: 'claude', api_key_ref: null, provider_options: {}, capabilities: [], @@ -136,7 +136,7 @@ describe('ritsu-agent capability tool gating', () => { name: 'Gamma', description: 'gamma-desc', system_prompt: 'be gamma', - dispatcher: 'claude-direct', + runtime: 'direct', model: 'claude-sonnet-4-6', }); assert.ok((ok).includes('created gamma')); @@ -146,7 +146,7 @@ describe('ritsu-agent capability tool gating', () => { name: 'Gamma', description: 'gamma-desc', system_prompt: 'be gamma', - dispatcher: 'claude-direct', + runtime: 'direct', model: 'claude-sonnet-4-6', }); assert.match(String(dup), /already exists/); diff --git a/src/__tests__/agent-definition-store.test.ts b/src/__tests__/agent-definition-store.test.ts index b60d991..7180874 100644 --- a/src/__tests__/agent-definition-store.test.ts +++ b/src/__tests__/agent-definition-store.test.ts @@ -11,12 +11,12 @@ function sampleDef(overrides: Partial = {}): AgentDefinition { name: 'Sample', description: 'a sample agent', system_prompt: 'be helpful', - dispatcher: 'claude-direct', + runtime: 'direct', model: 'claude-sonnet-4-6', memory_backend: 'sqlite', tools_allowlist: [], can_call: [], - provider: null, + provider: 'claude', api_key_ref: null, provider_options: {}, capabilities: [], diff --git a/src/__tests__/agent-host.test.ts b/src/__tests__/agent-host.test.ts index 6561734..b37b3df 100644 --- a/src/__tests__/agent-host.test.ts +++ b/src/__tests__/agent-host.test.ts @@ -23,12 +23,12 @@ function sampleDef(overrides: Partial = {}): AgentDefinition { name: 'Alice', description: 'a test agent', system_prompt: 'be brief', - dispatcher: 'claude-direct', + runtime: 'direct', model: 'claude-sonnet-4-6', memory_backend: 'sqlite', tools_allowlist: [], can_call: [], - provider: null, + provider: 'claude', api_key_ref: null, provider_options: {}, capabilities: [], diff --git a/src/__tests__/agent-monitor.test.ts b/src/__tests__/agent-monitor.test.ts index f09697e..05c6ad4 100644 --- a/src/__tests__/agent-monitor.test.ts +++ b/src/__tests__/agent-monitor.test.ts @@ -41,7 +41,7 @@ describe('ritsu-agent monitor tools honor allow_monitor_read', () => { conversations = new SqliteConversationStore(db); const mk = (id: string, allow: boolean) => defStore.upsert(AgentDefinitionSchema.parse({ id, type: 'generic', name: id, description: 'x', system_prompt: 'x', - dispatcher: 'claude-direct', model: 'claude-sonnet-4-6', allow_monitor_read: allow, + runtime: 'direct', model: 'claude-sonnet-4-6', allow_monitor_read: allow, })); await mk('opaque-agent', false); await mk('open-agent', true); diff --git a/src/__tests__/anthropic-client.test.ts b/src/__tests__/anthropic-client.test.ts new file mode 100644 index 0000000..c36245a --- /dev/null +++ b/src/__tests__/anthropic-client.test.ts @@ -0,0 +1,128 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { AnthropicClient, toAnthropicMessages } from '../model/ritsu-agent/anthropic-client.js'; +import type { RaMessage, RaTool } from '../model/ritsu-agent/types.js'; + +const PNG_1PX = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC'; + +interface Captured { url: string; apiKey: string | null; body: Record } + +function captureFetch(sink: Captured[], reply: unknown): typeof fetch { + return async (url: string | URL | Request, init?: RequestInit) => { + sink.push({ + url: url instanceof Request ? url.url : String(url), + apiKey: new Headers(init?.headers).get('x-api-key'), + body: JSON.parse(init?.body as string) as Record, + }); + return new Response(JSON.stringify(reply), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; +} + +const textReply = { + id: 'msg_1', type: 'message', role: 'assistant', model: 'claude-test-001', + content: [{ type: 'text', text: 'hello' }], + stop_reason: 'end_turn', stop_sequence: null, + usage: { input_tokens: 9, output_tokens: 2 }, +}; + +const toolReply = { + ...textReply, + content: [ + { type: 'text', text: 'calling echo' }, + { type: 'tool_use', id: 'toolu_1', name: 'echo', input: { msg: 'x' } }, + ], + stop_reason: 'tool_use', +}; + +const echoTool: RaTool = { + name: 'echo', + description: 'echo back', + parameters: { type: 'object', properties: { msg: { type: 'string' } } }, + handler: async () => 'ok', +}; + +describe('toAnthropicMessages', () => { + it('hoists system, maps roles, renders images as base64 source blocks', () => { + const messages: RaMessage[] = [ + { role: 'system', content: 'be terse' }, + { role: 'user', content: [ + { type: 'text', text: 'what is this?' }, + { type: 'image', media_type: 'image/png', data: PNG_1PX }, + ] }, + { role: 'assistant', content: 'a pixel' }, + ]; + const { system, turns } = toAnthropicMessages(messages); + assert.equal(system, 'be terse'); + assert.equal(turns.length, 2); + assert.deepEqual(turns[0].content, [ + { type: 'text', text: 'what is this?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: PNG_1PX } }, + ]); + assert.deepEqual(turns[1], { role: 'assistant', content: [{ type: 'text', text: 'a pixel' }] }); + }); + + it('merges parallel tool results into the single next user message', () => { + const messages: RaMessage[] = [ + { role: 'user', content: 'do both' }, + { role: 'assistant', content: '', tool_calls: [ + { id: 'toolu_a', type: 'function', function: { name: 'echo', arguments: '{"msg":"a"}' } }, + { id: 'toolu_b', type: 'function', function: { name: 'echo', arguments: '{"msg":"b"}' } }, + ] }, + { role: 'tool', tool_call_id: 'toolu_a', content: 'result-a' }, + { role: 'tool', tool_call_id: 'toolu_b', content: 'result-b' }, + ]; + const { turns } = toAnthropicMessages(messages); + assert.equal(turns.length, 3); + assert.deepEqual(turns[1].content, [ + { type: 'tool_use', id: 'toolu_a', name: 'echo', input: { msg: 'a' } }, + { type: 'tool_use', id: 'toolu_b', name: 'echo', input: { msg: 'b' } }, + ]); + assert.deepEqual(turns[2], { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_a', content: 'result-a' }, + { type: 'tool_result', tool_use_id: 'toolu_b', content: 'result-b' }, + ], + }); + }); +}); + +describe('AnthropicClient', () => { + it('hits the Messages API with the key header, default max_tokens, and input_schema tools', async () => { + const sink: Captured[] = []; + const client = new AnthropicClient({ apiKey: 'sk-ant-test', model: 'claude-test', fetchImpl: captureFetch(sink, toolReply) }); + + const out = await client.chat([{ role: 'user', content: 'run echo' }], [echoTool]); + + assert.equal(sink.length, 1); + assert.equal(sink[0].url, 'https://api.anthropic.com/v1/messages'); + assert.equal(sink[0].apiKey, 'sk-ant-test'); + assert.equal(sink[0].body.max_tokens, 4096); + assert.ok(!('temperature' in sink[0].body)); + assert.deepEqual(sink[0].body.tools, [ + { name: 'echo', description: 'echo back', input_schema: echoTool.parameters }, + ]); + + assert.equal(out.content, 'calling echo'); + assert.equal(out.model, 'claude-test-001'); + assert.deepEqual(out.tool_calls, [ + { id: 'toolu_1', type: 'function', function: { name: 'echo', arguments: '{"msg":"x"}' } }, + ]); + assert.deepEqual(out.usage, { prompt_tokens: 9, completion_tokens: 2, total_tokens: 11 }); + }); + + it('honors provider_options temperature/max_tokens', async () => { + const sink: Captured[] = []; + const client = new AnthropicClient({ + apiKey: 'sk-ant-test', model: 'claude-test', + providerOptions: { temperature: 0.3, max_tokens: 512 }, + fetchImpl: captureFetch(sink, textReply), + }); + await client.chat([{ role: 'user', content: 'hi' }], []); + assert.equal(sink[0].body.temperature, 0.3); + assert.equal(sink[0].body.max_tokens, 512); + }); +}); diff --git a/src/__tests__/db-migrate-runtime.test.ts b/src/__tests__/db-migrate-runtime.test.ts new file mode 100644 index 0000000..38ab962 --- /dev/null +++ b/src/__tests__/db-migrate-runtime.test.ts @@ -0,0 +1,88 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { openDatabase } from '../db.js'; + +/** The pre-runtime agent_definitions shape (dispatcher column + nullable + * provider), as it existed on deployed DBs. */ +const LEGACY_DDL = ` +CREATE TABLE agent_definitions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + system_prompt TEXT NOT NULL, + dispatcher TEXT NOT NULL CHECK (dispatcher IN ('claude-direct','litellm')), + model TEXT NOT NULL, + memory_backend TEXT NOT NULL DEFAULT 'sqlite', + tools_allowlist TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT 111, + updated_at INTEGER NOT NULL DEFAULT 222, + previous_system_prompt TEXT, + previous_saved_at INTEGER, + can_call TEXT NOT NULL DEFAULT '[]', + provider TEXT, + api_key_ref INTEGER, + provider_options TEXT NOT NULL DEFAULT '{}', + capabilities TEXT NOT NULL DEFAULT '[]', + approval_tools TEXT NOT NULL DEFAULT '[]', + plugins TEXT NOT NULL DEFAULT '[]', + escalation_approvable INTEGER NOT NULL DEFAULT 0, + allow_monitor_read INTEGER NOT NULL DEFAULT 0 +);`; + +describe('agent runtime migration (dispatcher → runtime/provider)', () => { + it('rebuilds legacy rows into the two-tier shape', () => { + const dbPath = join(mkdtempSync(join(tmpdir(), 'ritsu-migrate-')), 'legacy.db'); + const legacy = new DatabaseSync(dbPath); + legacy.exec(LEGACY_DDL); + legacy.exec(` + INSERT INTO agent_definitions (id, type, name, description, system_prompt, dispatcher, model) + VALUES ('plain', 'generic', 'Plain', 'claude-sdk agent', 'be plain', 'claude-direct', 'claude-sonnet-4-6'); + INSERT INTO agent_definitions (id, type, name, description, system_prompt, dispatcher, model, provider, api_key_ref) + VALUES ('keyed', 'generic', 'Keyed', 'openai agent', 'be keyed', 'claude-direct', 'gpt-test', 'openai', 7);`); + legacy.close(); + + const db = openDatabase(dbPath); + const rows = db.prepare('SELECT id, runtime, provider, api_key_ref FROM agent_definitions ORDER BY id').all() as + Array<{ id: string; runtime: string; provider: string; api_key_ref: number | null }>; + + const plain = rows.find(r => r.id === 'plain')!; + assert.deepEqual({ runtime: plain.runtime, provider: plain.provider }, { runtime: 'direct', provider: 'claude' }); + + const keyed = rows.find(r => r.id === 'keyed')!; + assert.deepEqual( + { runtime: keyed.runtime, provider: keyed.provider, ref: keyed.api_key_ref }, + { runtime: 'api', provider: 'openai', ref: 7 }, + ); + + const cols = db.prepare('PRAGMA table_info(agent_definitions)').all() as Array<{ name: string }>; + assert.ok(!cols.some(c => c.name === 'dispatcher'), 'dispatcher column is gone'); + + // Idempotent: reopening a migrated DB must not rebuild again. + db.close(); + const again = openDatabase(dbPath); + assert.equal((again.prepare('SELECT COUNT(*) AS n FROM agent_definitions').get() as { n: number }).n, rows.length); + again.close(); + }); + + it('maps openai-compat rows to openrouter', () => { + const dbPath = join(mkdtempSync(join(tmpdir(), 'ritsu-migrate-')), 'compat.db'); + const legacy = new DatabaseSync(dbPath); + legacy.exec(LEGACY_DDL); + legacy.exec(` + INSERT INTO agent_definitions (id, type, name, description, system_prompt, dispatcher, model, provider, api_key_ref) + VALUES ('router', 'generic', 'Router', 'openrouter agent', 'route', 'claude-direct', 'meta/some-model', 'openai-compat', 9);`); + legacy.close(); + + const db = openDatabase(dbPath); + const row = db.prepare("SELECT runtime, provider FROM agent_definitions WHERE id = 'router'").get() as + { runtime: string; provider: string }; + assert.deepEqual(row, { runtime: 'api', provider: 'openrouter' }); + db.close(); + }); +}); diff --git a/src/__tests__/gemini-client.test.ts b/src/__tests__/gemini-client.test.ts new file mode 100644 index 0000000..bd8a119 --- /dev/null +++ b/src/__tests__/gemini-client.test.ts @@ -0,0 +1,139 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import type { GenerateContentResponse } from '@google/genai'; +import { GeminiClient, toGeminiRequest, fromGeminiResponse } from '../model/ritsu-agent/gemini-client.js'; +import type { RaMessage, RaTool } from '../model/ritsu-agent/types.js'; + +const PNG_1PX = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +const echoTool: RaTool = { + name: 'echo', + description: 'echo back', + parameters: { type: 'object', properties: { msg: { type: 'string' } }, required: ['msg'] }, + handler: async () => 'ok', +}; + +function response(partial: Partial): GenerateContentResponse { + return partial as GenerateContentResponse; +} + +describe('toGeminiRequest', () => { + it('maps roles, hoists system to systemInstruction, sends tools as raw JSON Schema', () => { + const messages: RaMessage[] = [ + { role: 'system', content: 'be terse' }, + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + { role: 'user', content: 'again' }, + ]; + const req = toGeminiRequest('gemini-test', messages, [echoTool], { temperature: 0.2, max_tokens: 100 }); + + assert.equal(req.model, 'gemini-test'); + assert.equal(req.config?.systemInstruction, 'be terse'); + assert.equal(req.config?.temperature, 0.2); + assert.equal(req.config?.maxOutputTokens, 100); + assert.deepEqual( + (req.contents as Array<{ role: string }>).map(c => c.role), + ['user', 'model', 'user'], + ); + const decls = (req.config?.tools as Array<{ functionDeclarations: Array> }>)[0].functionDeclarations; + assert.equal(decls[0].name, 'echo'); + assert.deepEqual(decls[0].parametersJsonSchema, echoTool.parameters); + }); + + it('omits sampling params unless set in provider_options', () => { + const req = toGeminiRequest('m', [{ role: 'user', content: 'hi' }], []); + assert.equal(req.config, undefined); + }); + + it('renders image blocks as inlineData parts', () => { + const messages: RaMessage[] = [ + { role: 'user', content: [ + { type: 'text', text: 'what is this?' }, + { type: 'image', media_type: 'image/png', data: PNG_1PX }, + ] }, + ]; + const req = toGeminiRequest('m', messages, []); + const parts = (req.contents as Array<{ parts: Array> }>)[0].parts; + assert.deepEqual(parts[0], { text: 'what is this?' }); + assert.deepEqual(parts[1], { inlineData: { mimeType: 'image/png', data: PNG_1PX } }); + }); + + it('renders tool history as functionCall/functionResponse pairs, never echoing synthesized ids', () => { + const messages: RaMessage[] = [ + { role: 'user', content: 'do it' }, + { role: 'assistant', content: '', tool_calls: [ + { id: 'gcall_0_echo', type: 'function', function: { name: 'echo', arguments: '{"msg":"x"}' } }, + { id: 'api-id-1', type: 'function', function: { name: 'echo', arguments: '{"msg":"y"}' } }, + ] }, + { role: 'tool', tool_call_id: 'gcall_0_echo', content: 'result-x' }, + { role: 'tool', tool_call_id: 'api-id-1', content: 'result-y' }, + ]; + const req = toGeminiRequest('m', messages, [echoTool]); + const contents = req.contents as Array<{ role: string; parts: Array> }>; + + const modelTurn = contents[1]; + assert.equal(modelTurn.role, 'model'); + assert.deepEqual(modelTurn.parts[0].functionCall, { name: 'echo', args: { msg: 'x' } }); + assert.deepEqual(modelTurn.parts[1].functionCall, { id: 'api-id-1', name: 'echo', args: { msg: 'y' } }); + + assert.deepEqual(contents[2].parts[0].functionResponse, { name: 'echo', response: { output: 'result-x' } }); + assert.deepEqual(contents[3].parts[0].functionResponse, { id: 'api-id-1', name: 'echo', response: { output: 'result-y' } }); + }); +}); + +describe('fromGeminiResponse', () => { + it('joins text parts, skips thoughts, synthesizes ids for id-less function calls', () => { + const res = response({ + modelVersion: 'gemini-test-001', + candidates: [{ + content: { role: 'model', parts: [ + { text: 'thinking...', thought: true }, + { text: 'calling ' }, + { text: 'echo' }, + { functionCall: { name: 'echo', args: { msg: 'x' } } }, + ] }, + }], + usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 3, totalTokenCount: 10 }, + }); + const out = fromGeminiResponse(res, 'fallback'); + assert.equal(out.content, 'calling echo'); + assert.equal(out.model, 'gemini-test-001'); + assert.deepEqual(out.usage, { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 }); + assert.equal(out.tool_calls.length, 1); + assert.equal(out.tool_calls[0].id, 'gcall_0_echo'); + assert.equal(out.tool_calls[0].function.arguments, '{"msg":"x"}'); + }); + + it('keeps API-issued function-call ids', () => { + const res = response({ + candidates: [{ content: { role: 'model', parts: [{ functionCall: { id: 'api-id-9', name: 'echo', args: {} } }] } }], + }); + assert.equal(fromGeminiResponse(res, 'm').tool_calls[0].id, 'api-id-9'); + }); + + it('throws with the block reason when the prompt was refused', () => { + const res = response({ promptFeedback: { blockReason: 'SAFETY' } } as Partial); + assert.throws(() => fromGeminiResponse(res, 'm'), /blocked.*SAFETY/); + }); +}); + +describe('GeminiClient', () => { + it('runs a full chat round through an injected generateContent', async () => { + const seen: unknown[] = []; + const client = new GeminiClient({ + apiKey: 'test-key', + model: 'gemini-test', + generateContentImpl: async req => { + seen.push(req); + return response({ + candidates: [{ content: { role: 'model', parts: [{ text: 'four' }] } }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1, totalTokenCount: 6 }, + }); + }, + }); + const out = await client.chat([{ role: 'user', content: '2+2?' }], []); + assert.equal(out.content, 'four'); + assert.equal(seen.length, 1); + assert.equal((seen[0] as { model: string }).model, 'gemini-test'); + }); +}); diff --git a/src/__tests__/image-attachments.test.ts b/src/__tests__/image-attachments.test.ts index 51785d8..22391b3 100644 --- a/src/__tests__/image-attachments.test.ts +++ b/src/__tests__/image-attachments.test.ts @@ -97,7 +97,7 @@ describe('ritsu-agent OpenAI client (image_url translation)', () => { it('renders an image block as a data-URL image_url part', async () => { const sink: { body: CapturedBody | null } = { body: null }; const client = new OpenAICompatClient({ - provider: 'openai', apiKey: 'sk-test', model: 'gpt-4o', fetchImpl: captureFetch(sink, 'a cat'), + provider: 'openrouter', apiKey: 'sk-test', model: 'gpt-4o', fetchImpl: captureFetch(sink, 'a cat'), }); const messages: RaMessage[] = [ { role: 'user', content: [ @@ -117,7 +117,7 @@ describe('ritsu-agent OpenAI client (image_url translation)', () => { it('leaves a plain-string message as a string (no needless multi-part)', async () => { const sink: { body: CapturedBody | null } = { body: null }; const client = new OpenAICompatClient({ - provider: 'openai', apiKey: 'sk-test', model: 'gpt-4o', fetchImpl: captureFetch(sink, 'hi'), + provider: 'openrouter', apiKey: 'sk-test', model: 'gpt-4o', fetchImpl: captureFetch(sink, 'hi'), }); await client.chat([{ role: 'user', content: 'hi' }], []); assert.equal(sink.body?.messages[0].content, 'hi'); diff --git a/src/__tests__/openai-sdk-client.test.ts b/src/__tests__/openai-sdk-client.test.ts new file mode 100644 index 0000000..6130b72 --- /dev/null +++ b/src/__tests__/openai-sdk-client.test.ts @@ -0,0 +1,120 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { OpenAISdkClient } from '../model/ritsu-agent/openai-sdk-client.js'; +import type { RaTool } from '../model/ritsu-agent/types.js'; + +interface Captured { url: string; auth: string | null; body: Record } + +/** Capture the SDK's outgoing request and reply with a canned completion. */ +function captureFetch(sink: Captured[], reply: unknown): typeof fetch { + return async (url: string | URL | Request, init?: RequestInit) => { + sink.push({ + url: url instanceof Request ? url.url : String(url), + auth: new Headers(init?.headers).get('authorization'), + body: JSON.parse(init?.body as string) as Record, + }); + return new Response(JSON.stringify(reply), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; +} + +const reply = { + id: 'c1', model: 'gpt-test-001', object: 'chat.completion', + choices: [{ + index: 0, + message: { + role: 'assistant', content: null, + tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'echo', arguments: '{"msg":"x"}' } }], + }, + finish_reason: 'tool_calls', + }], + usage: { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 }, +}; + +const echoTool: RaTool = { + name: 'echo', + description: 'echo back', + parameters: { type: 'object', properties: { msg: { type: 'string' } } }, + handler: async () => 'ok', +}; + +describe('OpenAISdkClient', () => { + it('hits the official endpoint with the bearer key and translates tool calls back', async () => { + const sink: Captured[] = []; + const client = new OpenAISdkClient({ apiKey: 'sk-test', model: 'gpt-test', fetchImpl: captureFetch(sink, reply) }); + + const out = await client.chat([{ role: 'user', content: 'run echo' }], [echoTool]); + + assert.equal(sink.length, 1); + assert.equal(sink[0].url, 'https://api.openai.com/v1/chat/completions'); + assert.equal(sink[0].auth, 'Bearer sk-test'); + assert.deepEqual(sink[0].body.tools, [{ + type: 'function', + function: { name: 'echo', description: 'echo back', parameters: echoTool.parameters }, + }]); + assert.equal(sink[0].body.tool_choice, 'auto'); + + assert.equal(out.model, 'gpt-test-001'); + assert.deepEqual(out.tool_calls, [ + { id: 'call_1', type: 'function', function: { name: 'echo', arguments: '{"msg":"x"}' } }, + ]); + assert.deepEqual(out.usage, { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 }); + }); + + it('sends sampling params only when set (reasoning models reject defaults)', async () => { + const sink: Captured[] = []; + const bare = new OpenAISdkClient({ apiKey: 'sk-test', model: 'gpt-test', fetchImpl: captureFetch(sink, reply) }); + await bare.chat([{ role: 'user', content: 'hi' }], []); + assert.ok(!('temperature' in sink[0].body)); + assert.ok(!('max_tokens' in sink[0].body)); + assert.ok(!('max_completion_tokens' in sink[0].body)); + + const tuned = new OpenAISdkClient({ + apiKey: 'sk-test', model: 'gpt-test', + providerOptions: { temperature: 0.1, max_tokens: 256 }, + fetchImpl: captureFetch(sink, reply), + }); + await tuned.chat([{ role: 'user', content: 'hi' }], []); + assert.equal(sink[1].body.temperature, 0.1); + assert.equal(sink[1].body.max_completion_tokens, 256); + assert.ok(!('max_tokens' in sink[1].body)); + }); + + it('honors a base_url override', async () => { + const sink: Captured[] = []; + const client = new OpenAISdkClient({ + apiKey: 'sk-test', model: 'gpt-test', + providerOptions: { base_url: 'http://localhost:9999/v1/' }, + fetchImpl: captureFetch(sink, reply), + }); + await client.chat([{ role: 'user', content: 'hi' }], []); + assert.equal(sink[0].url, 'http://localhost:9999/v1/chat/completions'); + }); +}); + +describe('OpenAICompatClient provider defaults', () => { + it('xai routes to api.x.ai; keyless litellm sends no Authorization header', async () => { + const { OpenAICompatClient } = await import('../model/ritsu-agent/openai-client.js'); + const seen: Array<{ url: string; auth: string | null }> = []; + const fetchImpl: typeof fetch = async (url, init) => { + seen.push({ url: url instanceof Request ? url.url : String(url), auth: new Headers(init?.headers).get('authorization') }); + return new Response(JSON.stringify(reply), { status: 200, headers: { 'content-type': 'application/json' } }); + }; + await new OpenAICompatClient({ provider: 'xai', apiKey: 'xai-test', model: 'grok-test', fetchImpl }) + .chat([{ role: 'user', content: 'hi' }], []); + assert.equal(seen[0].url, 'https://api.x.ai/v1/chat/completions'); + assert.equal(seen[0].auth, 'Bearer xai-test'); + + await new OpenAICompatClient({ provider: 'litellm', apiKey: '', model: 'm', fetchImpl }) + .chat([{ role: 'user', content: 'hi' }], []); + assert.equal(seen[1].url, 'http://localhost:4000/v1/chat/completions'); + assert.equal(seen[1].auth, null); + }); + + it("custom without base_url refuses to construct", async () => { + const { OpenAICompatClient } = await import('../model/ritsu-agent/openai-client.js'); + assert.throws(() => new OpenAICompatClient({ provider: 'custom', apiKey: 'k', model: 'm' }), /base_url/); + }); +}); diff --git a/src/__tests__/ritsu-agent-dispatcher.test.ts b/src/__tests__/ritsu-agent-dispatcher.test.ts index 15cf42f..5f4e94f 100644 --- a/src/__tests__/ritsu-agent-dispatcher.test.ts +++ b/src/__tests__/ritsu-agent-dispatcher.test.ts @@ -28,18 +28,17 @@ async function waitFor(pred: () => boolean, tries = 100): Promise { } /** Build a fake fetch that returns a queued series of OpenAI-shape - * responses. Each call dequeues one. */ + * responses. Each call dequeues one. Real Response objects — the official + * openai SDK reads headers, not just .json(). */ function makeFetchQueue(responses: unknown[]): typeof fetch { let i = 0; return (async (_url: unknown, _init: unknown) => { const body = responses[i++]; if (!body) throw new Error('fetch queue exhausted'); - return { - ok: true, + return new Response(JSON.stringify(body), { status: 200, - text: async () => JSON.stringify(body), - json: async () => body, - } as unknown as Response; + headers: { 'content-type': 'application/json' }, + }); }); } diff --git a/src/admin/app.css b/src/admin/app.css index baba456..7b31d92 100644 --- a/src/admin/app.css +++ b/src/admin/app.css @@ -880,3 +880,9 @@ body.panel-open { overflow: hidden; touch-action: none; } font-size: 0.8rem; margin-bottom: 8px; line-height: 1.4; } .approval-escalation-banner code, .approval-escalation-banner strong { color: inherit; } + +/* System -> Health status pills */ +.hstat { padding: 1px 10px; border-radius: 10px; border: 1px solid var(--line); font-size: 12px; } +.hstat-ok { color: var(--ok); border-color: #2c4a36; } +.hstat-fail { color: var(--err); border-color: #4a2c2c; } +.hstat-skip { color: var(--muted); } diff --git a/src/admin/app.js b/src/admin/app.js index 6b5a0d2..a6d1989 100644 --- a/src/admin/app.js +++ b/src/admin/app.js @@ -225,6 +225,7 @@ const NAV_GROUPS = [ { id: 'oauth-clients', label: 'OAuth Clients' }, ] }, { id: 'system', label: 'System', tabs: [ + { id: 'health', label: 'Health' }, { id: 'logs', label: 'Logs' }, { id: 'audit', label: 'Audit' }, { id: 'plugins', label: 'Plugins' }, @@ -290,6 +291,7 @@ function switchTab(name) { else if (name === 'audit') loadAuditTab(); else if (name === 'plugins') loadPluginsManager(); else if (name === 'backups') loadBackupsTab(); + else if (name === 'health') loadHealthTab(); if (name !== 'logs') closeLogStream(); } @@ -404,7 +406,7 @@ function renderAgents() { const stateF = $('agent-state-filter')?.value || ''; const filtered = agentCache.filter(a => { if (q && !(a.id + ' ' + a.name + ' ' + a.description).toLowerCase().includes(q)) return false; - if (disp && a.dispatcher !== disp) return false; + if (disp && a.runtime !== disp) return false; if (stateF === 'on' && !a.enabled) return false; if (stateF === 'off' && a.enabled) return false; return true; @@ -418,7 +420,7 @@ function renderAgents() { ${glyphFor(a.id)}${esc(a.id)} ${esc(a.type)} ${esc(a.name)} - ${esc(a.dispatcher)} ${esc(a.model)} + ${esc(a.runtime)}:${esc(a.provider)} ${esc(a.model)} ${esc(a.memory_backend)} ${fmtRelative(a.last_used_at)} ${a.enabled ? 'on' : 'off'} @@ -429,7 +431,7 @@ function renderAgents() { `).join(''); - target.innerHTML = `${rows}
idtypenamedispatcher / modelmemorylast usedstate
`; + target.innerHTML = `${rows}
idtypenameruntime / modelmemorylast usedstate
`; } /** id of the agent currently loaded into the form, or null if drafting a new one. */ let editingAgentId = null; @@ -475,11 +477,34 @@ async function refreshApiKeyDropdown() { function renderApiKeyDropdown(selectedId) { const sel = $('f-api-key-ref'); if (!sel) return; - const opts = [''] + const opts = [''] .concat(apiKeyCache.map(k => ``)); sel.innerHTML = opts.join(''); if (selectedId != null) sel.value = String(selectedId); } + +/** Provider choices per runtime tier. direct = vendor runtimes riding a + * subscription; api = metered providers behind ritsu's own loop. */ +const RUNTIME_PROVIDERS = { + direct: [ + { value: 'claude', label: 'claude (Agent SDK, Max plan)' }, + ], + api: [ + { value: 'anthropic', label: 'anthropic (official SDK)' }, + { value: 'openai', label: 'openai (official SDK)' }, + { value: 'gemini', label: 'gemini (official SDK)' }, + { value: 'xai', label: 'xai / grok (api.x.ai)' }, + { value: 'openrouter', label: 'openrouter (any model, one key)' }, + { value: 'litellm', label: 'litellm proxy (key optional)' }, + { value: 'custom', label: 'custom (OpenAI-compatible base_url)' }, + ], +}; +function renderProviderDropdown(runtime, selected) { + const sel = $('f-provider'); + const list = RUNTIME_PROVIDERS[runtime] ?? RUNTIME_PROVIDERS.direct; + sel.innerHTML = list.map(p => ``).join(''); + sel.value = list.some(p => p.value === selected) ? selected : list[0].value; +} function loadAgentForm(a) { editingAgentId = a.id; editingWorkspaces = null; // force refetch on next recomputeFormWarnings @@ -487,14 +512,14 @@ function loadAgentForm(a) { $('f-type').value = a.type; $('f-name').value = a.name; $('f-description').value = a.description; - $('f-dispatcher').value = a.dispatcher; + $('f-runtime').value = a.runtime ?? 'direct'; + renderProviderDropdown(a.runtime ?? 'direct', a.provider ?? 'claude'); $('f-model').value = a.model; $('f-memory-backend').value = a.memory_backend; $('f-enabled').checked = !!a.enabled; $('f-escalation-approvable').checked = !!a.escalation_approvable; $('f-allow-monitor-read').checked = !!a.allow_monitor_read; $('f-system-prompt').value = a.system_prompt; - $('f-provider').value = a.provider ?? ''; renderApiKeyDropdown(a.api_key_ref ?? null); $('f-provider-options').value = a.provider_options && Object.keys(a.provider_options).length ? JSON.stringify(a.provider_options) @@ -529,7 +554,8 @@ function clearForm() { editingWorkspaces = []; // new draft: definitively no workspaces $('agent-form').reset(); $('f-id').readOnly = false; - $('f-provider').value = ''; + $('f-runtime').value = 'direct'; + renderProviderDropdown('direct', 'claude'); $('f-provider-options').value = ''; renderApiKeyDropdown(null); refreshApiKeyDropdown(); @@ -548,7 +574,8 @@ function clearForm() { recomputeFormWarnings(); } async function submitAgent(method) { - const provider = $('f-provider').value || null; + const runtime = $('f-runtime').value; + const provider = $('f-provider').value; const apiKeyRefRaw = $('f-api-key-ref').value; const apiKeyRef = apiKeyRefRaw ? Number(apiKeyRefRaw) : null; const providerOptsRaw = $('f-provider-options').value.trim(); @@ -559,7 +586,7 @@ async function submitAgent(method) { } const body = { id: $('f-id').value, type: $('f-type').value, name: $('f-name').value, - description: $('f-description').value, dispatcher: $('f-dispatcher').value, + description: $('f-description').value, runtime, model: $('f-model').value, memory_backend: $('f-memory-backend').value, enabled: $('f-enabled').checked, system_prompt: $('f-system-prompt').value, tools_allowlist: readToolsAllowlist(), @@ -700,7 +727,9 @@ async function runTest() { const r = await api('POST', '/admin/api/test', { system_prompt: $('f-system-prompt').value, message: msg, - dispatcher: $('f-dispatcher').value, + runtime: $('f-runtime').value, + provider: $('f-provider').value, + api_key_ref: $('f-api-key-ref').value ? Number($('f-api-key-ref').value) : null, model: $('f-model').value, tools_allowlist: readToolsAllowlist(), workspaces: wsList.map(w => ({ path: w.path, permissions: w.permissions })), @@ -976,7 +1005,7 @@ async function openAgentPanel(agentId) { const summaries = { conversations: [{ id: canonical.id }] }; panelConvoId = summaries.conversations[0]?.id ?? null; $('ap-dot').className = `tile-dot ${def.enabled ? 'idle' : 'off'}`; - $('ap-sub').textContent = `${def.model} · ${def.dispatcher}${def.enabled ? '' : ' · disabled'}`; + $('ap-sub').textContent = `${def.model} · ${def.runtime}:${def.provider}${def.enabled ? '' : ' · disabled'}`; panelModel = def.model; clearAttachments(); updateAttachHint(); @@ -1154,7 +1183,7 @@ function attachMaxEdge() { const ATTACH_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']; // Best-effort vision-capability guess. Unknown models default to "yes" so we // don't nag; the warning only fires for models we're fairly sure are text-only. -const VISION_MODEL_RE = /claude|gpt-4o|gpt-4\.1|gpt-4-turbo|gpt-4-vision|chatgpt-4o|o1|o3|o4|gemini|llava|pixtral|qwen.*vl|llama.*vision|vision|moondream/i; +const VISION_MODEL_RE = /claude|gpt-4o|gpt-4\.1|gpt-4-turbo|gpt-4-vision|chatgpt-4o|o1|o3|o4|gemini|grok|llava|pixtral|qwen.*vl|llama.*vision|vision|moondream/i; function blobToBase64(blob) { return new Promise((resolve, reject) => { @@ -2254,11 +2283,11 @@ async function renderToolsFor(agentId) { }` : '(none — FS-touching tools will fail at runtime)'; - // Surface the runtime / dispatcher next to the tool inventory so the - // claude-sdk-vs-ritsu-agent distinction is unambiguous from here. - const runtime = agent.provider - ? `ritsu-agent (${esc(agent.provider)})` - : 'claude-sdk (Max plan)'; + // Surface the runtime tier next to the tool inventory so the + // plan-vs-metered distinction is unambiguous from here. + const runtime = agent.runtime === 'api' + ? `api (${esc(agent.provider)}, metered)` + : `direct (${esc(agent.provider)}, plan)`; target.innerHTML = `
@@ -2900,6 +2929,7 @@ const ACTIONS = { 'uninstall-plugin': (el) => uninstallPlugin(el.dataset.id, el.dataset.name, el.dataset.tables), 'load-plugin-agent': (el) => loadPluginAgent(el.dataset.id), 'backup-now': () => backupNow(), + 'health-refresh': () => loadHealthTab(), 'backup-export': () => downloadWithAuth('/admin/api/export', `ritsu-export-${new Date().toISOString().slice(0, 10)}.json`), 'backup-download': (el) => downloadWithAuth(`/admin/api/backups/${encodeURIComponent(el.dataset.name)}`, el.dataset.name), 'backup-delete': (el) => deleteBackupFile(el.dataset.name), @@ -3050,6 +3080,34 @@ async function togglePlugin(id, currentlyEnabled) { } // ---- Backups -------------------------------------------------------------- +async function loadHealthTab() { + const target = $('health-list'); + const meta = $('health-meta'); + target.innerHTML = 'running checks…'; + meta.textContent = ''; + try { + const t0 = Date.now(); + const { checks } = await api('GET', '/admin/api/health'); + const groups = ['core', 'providers', 'connectors']; + const rows = groups.flatMap(g => { + const inGroup = checks.filter(c => c.group === g); + return inGroup.map((c, i) => ` + + ${i === 0 ? `${esc(g)}` : ''} + ${esc(c.label)} + ${c.status === 'skip' ? '—' : esc(c.status)} + ${c.latency_ms != null ? `${c.latency_ms} ms` : ''} + ${esc(c.detail ?? '')} + `); + }).join(''); + target.innerHTML = `${rows}
checkstatuslatencydetail
`; + const bad = checks.filter(c => c.status === 'fail').length; + meta.textContent = `${checks.length} checks, ${bad} failing · ${Date.now() - t0} ms`; + } catch (e) { + target.innerHTML = `health check failed: ${esc(e.message)}`; + } +} + async function loadBackupsTab() { const el = document.getElementById('backups-list'); if (!el) return; @@ -3145,6 +3203,10 @@ startTilesPolling(); // Live filters on the Agents tab (debounced search, instant selects). $('agent-search')?.addEventListener('input', debounce(applyAgentFilters, 100)); $('agent-disp-filter')?.addEventListener('change', applyAgentFilters); +// Runtime tier drives which providers the form offers; keep the current +// selection when it survives the switch. +$('f-runtime')?.addEventListener('change', () => renderProviderDropdown($('f-runtime').value, $('f-provider').value)); +renderProviderDropdown($('f-runtime')?.value || 'direct', 'claude'); $('agent-state-filter')?.addEventListener('change', applyAgentFilters); // Re-run cross-tab warnings whenever the form changes shape. diff --git a/src/admin/health.ts b/src/admin/health.ts new file mode 100644 index 0000000..b73f29c --- /dev/null +++ b/src/admin/health.ts @@ -0,0 +1,185 @@ +/** + * Live connectivity checks for System → Health. Read-only probes with short + * timeouts; details carry HTTP status / error text only, never key material. + */ +import { homedir } from 'node:os'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ApiKeyStore } from '../auth/api-key-store.js'; +import type { AgentDefinitionStore } from '../agent-definition-store.js'; +import { stripTrailingSlashes } from '../util/path-utils.js'; +import { LITELLM_NS } from '../model/ritsu-agent/client.js'; +import { FLASHBACK_NS } from '../memory/config.js'; +import { EMAIL_NS, EMAIL_SECRET_KEYS } from '../connectors/email.js'; +import { TWITTER_NS, TWITTER_SECRET_KEYS } from '../connectors/twitter.js'; +import { LINKEDIN_NS, LINKEDIN_SECRET_KEYS } from '../connectors/linkedin.js'; +import { INGEST_NS } from '../ingestion/extractors.js'; + +export type HealthStatus = 'ok' | 'fail' | 'skip'; + +export interface HealthCheck { + id: string; + label: string; + group: 'core' | 'providers' | 'connectors'; + status: HealthStatus; + latency_ms?: number; + detail?: string; +} + +/** Narrow store views so tests can stub them. */ +export interface HealthDeps { + defStore: Pick; + apiKeys: Pick; + secrets: { get(namespace: string, name: string): string | null }; + fetchImpl?: typeof fetch; + claudeCredsPath?: string; + timeoutMs?: number; +} + +/** Cheap authenticated endpoints (model lists) — verify key + reachability + * without spending tokens. */ +const PROVIDER_PROBES: Record { url: string; headers: Record }> = { + anthropic: k => ({ url: 'https://api.anthropic.com/v1/models', headers: { 'x-api-key': k, 'anthropic-version': '2023-06-01' } }), + openai: k => ({ url: 'https://api.openai.com/v1/models', headers: { Authorization: `Bearer ${k}` } }), + gemini: k => ({ url: 'https://generativelanguage.googleapis.com/v1beta/models', headers: { 'x-goog-api-key': k } }), + xai: k => ({ url: 'https://api.x.ai/v1/models', headers: { Authorization: `Bearer ${k}` } }), + openrouter: k => ({ url: 'https://openrouter.ai/api/v1/models', headers: { Authorization: `Bearer ${k}` } }), +}; + +export async function runHealthChecks(deps: HealthDeps): Promise<{ checks: HealthCheck[]; ran_at: number }> { + const fetchImpl = deps.fetchImpl ?? fetch; + const timeoutMs = deps.timeoutMs ?? 4000; + const probe = (url: string, headers: Record = {}) => probeHttp(url, headers, fetchImpl, timeoutMs); + + const tasks: Array> = [ + dbCheck(deps.defStore), + Promise.resolve(claudeCheck(deps.claudeCredsPath)), + ...deps.apiKeys.list().filter(k => !k.revoked_at).map(k => providerKeyCheck(k.id, k.name, k.provider, deps, probe)), + litellmProxyCheck(deps.secrets, probe), + flashbackCheck(deps.secrets, probe), + searxngCheck(probe), + ingestCheck(deps.secrets, probe), + Promise.resolve(configuredCheck('email', 'Email (IMAP/SMTP)', deps.secrets, EMAIL_NS, EMAIL_SECRET_KEYS)), + Promise.resolve(configuredCheck('twitter', 'X / Twitter', deps.secrets, TWITTER_NS, TWITTER_SECRET_KEYS)), + Promise.resolve(configuredCheck('linkedin', 'LinkedIn', deps.secrets, LINKEDIN_NS, LINKEDIN_SECRET_KEYS)), + ]; + + const settled = await Promise.allSettled(tasks); + const checks = settled.map((s, i) => + s.status === 'fulfilled' + ? s.value + : { id: `check-${i}`, label: 'internal', group: 'core' as const, status: 'fail' as const, detail: String(s.reason) }, + ); + return { checks, ran_at: Math.floor(Date.now() / 1000) }; +} + +async function dbCheck(defStore: HealthDeps['defStore']): Promise { + const t0 = Date.now(); + try { + const defs = await defStore.list(); + return { id: 'db', label: 'Database (SQLite)', group: 'core', status: 'ok', latency_ms: Date.now() - t0, detail: `${defs.length} agents` }; + } catch (e) { + return { id: 'db', label: 'Database (SQLite)', group: 'core', status: 'fail', detail: (e as Error).message }; + } +} + +function claudeCheck(credsPath?: string): HealthCheck { + const p = credsPath ?? join(homedir(), '.claude', '.credentials.json'); + return existsSync(p) + ? { id: 'claude-session', label: 'Claude session (direct runtime)', group: 'core', status: 'ok' } + : { id: 'claude-session', label: 'Claude session (direct runtime)', group: 'core', status: 'fail', detail: 'credentials missing — run `claude login` as the service user' }; +} + +async function providerKeyCheck( + id: number, + name: string, + provider: string, + deps: HealthDeps, + probe: (url: string, headers?: Record) => Promise, +): Promise { + const base = { id: `key-${id}`, label: `${name} (${provider})`, group: 'providers' as const }; + if (provider === 'custom') return { ...base, status: 'skip', detail: 'no fixed endpoint to probe' }; + const revealed = deps.apiKeys.reveal(id); + if (!revealed) return { ...base, status: 'fail', detail: 'key not revealable' }; + if (provider === 'litellm') { + const url = litellmModelsUrl(deps.secrets); + return { ...base, ...(await probe(url, { Authorization: `Bearer ${revealed.plaintext}` })) }; + } + const p = PROVIDER_PROBES[provider]; + if (!p) return { ...base, status: 'skip', detail: `no probe for provider '${provider}'` }; + const { url, headers } = p(revealed.plaintext); + return { ...base, ...(await probe(url, headers)) }; +} + +async function litellmProxyCheck(secrets: HealthDeps['secrets'], probe: ProbeFn): Promise { + const base = { id: 'litellm-proxy', label: 'LiteLLM proxy', group: 'connectors' as const }; + if (!secrets.get(LITELLM_NS, 'url')?.trim()) return { ...base, status: 'skip', detail: 'not configured' }; + const key = secrets.get(LITELLM_NS, 'api_key')?.trim(); + return { ...base, ...(await probe(litellmModelsUrl(secrets), key ? { Authorization: `Bearer ${key}` } : {})) }; +} + +async function flashbackCheck(secrets: HealthDeps['secrets'], probe: ProbeFn): Promise { + const base = { id: 'flashback', label: 'Flashback (memory)', group: 'connectors' as const }; + const url = secrets.get(FLASHBACK_NS, 'url')?.trim(); + if (!url) return { ...base, status: 'skip', detail: 'not configured' }; + const token = secrets.get(FLASHBACK_NS, 'token')?.trim(); + return { ...base, ...(await probe(`${stripTrailingSlashes(url)}/health`, token ? { Authorization: `Bearer ${token}` } : {})) }; +} + +async function searxngCheck(probe: ProbeFn): Promise { + const base = { id: 'searxng', label: 'SearXNG (web search)', group: 'connectors' as const }; + const url = process.env.RITSU_SEARXNG_URL?.trim(); + if (!url) return { ...base, status: 'skip', detail: 'not configured' }; + return { ...base, ...(await probe(stripTrailingSlashes(url))) }; +} + +async function ingestCheck(secrets: HealthDeps['secrets'], probe: ProbeFn): Promise { + const base = { id: 'ingest', label: 'Ingest / vision model', group: 'connectors' as const }; + const endpoint = secrets.get(INGEST_NS, 'endpoint')?.trim(); + if (!endpoint) return { ...base, status: 'skip', detail: 'not configured' }; + const key = secrets.get(INGEST_NS, 'api_key')?.trim(); + return { ...base, ...(await probe(v1ModelsUrl(endpoint), key ? { Authorization: `Bearer ${key}` } : {})) }; +} + +/** Connectors without a cheap safe probe (IMAP handshake, rate-limited or + * scope-limited vendor APIs) report configured/not — no live call. */ +function configuredCheck(id: string, label: string, secrets: HealthDeps['secrets'], ns: string, keys: readonly string[]): HealthCheck { + const set = keys.filter(k => secrets.get(ns, k)?.trim()); + if (set.length === 0) return { id, label, group: 'connectors', status: 'skip', detail: 'not configured' }; + return set.length === keys.length + ? { id, label, group: 'connectors', status: 'ok', detail: 'configured (no live probe)' } + : { id, label, group: 'connectors', status: 'fail', detail: `partially configured (${set.length}/${keys.length} keys set)` }; +} + +function litellmModelsUrl(secrets: HealthDeps['secrets']): string { + const url = secrets.get(LITELLM_NS, 'url')?.trim() || 'http://localhost:4000/v1'; + return v1ModelsUrl(url); +} + +function v1ModelsUrl(endpoint: string): string { + const base = stripTrailingSlashes(endpoint); + return base.endsWith('/v1') ? `${base}/models` : `${base}/v1/models`; +} + +interface ProbeResult { status: HealthStatus; latency_ms: number; detail?: string } +type ProbeFn = (url: string, headers?: Record) => Promise; + +async function probeHttp(url: string, headers: Record, fetchImpl: typeof fetch, timeoutMs: number): Promise { + const t0 = Date.now(); + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { headers, signal: ctl.signal }); + const latency_ms = Date.now() - t0; + return res.ok ? { status: 'ok', latency_ms } : { status: 'fail', latency_ms, detail: `HTTP ${res.status}` }; + } catch (e) { + const err = e as Error; + return { + status: 'fail', + latency_ms: Date.now() - t0, + detail: err.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : err.message, + }; + } finally { + clearTimeout(timer); + } +} diff --git a/src/admin/schema.ts b/src/admin/schema.ts index 6ca1599..781adc7 100644 --- a/src/admin/schema.ts +++ b/src/admin/schema.ts @@ -1,26 +1,42 @@ import { z } from 'zod'; -export const DispatcherKindSchema = z.enum(['claude-direct', 'litellm']); +/** The two-tier runtime model. + * - `direct`: the vendor's own agent runtime, riding a subscription + * (claude today; chatgpt/gemini/grok land as their dispatchers ship). + * - `api`: ritsu's own tool loop against a metered model API. */ +export const RuntimeSchema = z.enum(['direct', 'api']); + +/** Vendors available under the `direct` runtime. Grows one entry per + * vendor-runtime dispatcher we ship. */ +export const DIRECT_PROVIDERS = ['claude'] as const; + +/** Providers available under the `api` runtime. anthropic/openai/gemini use + * official SDKs; xai's documented path is its OpenAI-compatible API; + * openrouter/litellm/custom share the generic wire client. */ +export const API_PROVIDERS = ['anthropic', 'openai', 'gemini', 'xai', 'openrouter', 'litellm', 'custom'] as const; + +/** api-runtime providers that may run keyless (local proxy / custom + * endpoint); every other api provider requires an api_key_ref. */ +export const KEYLESS_API_PROVIDERS: readonly string[] = ['litellm', 'custom']; + export const MemoryBackendSchema = z.enum(['sqlite', 'flashback']); -export const AgentDefinitionSchema = z.object({ +const AgentDefinitionBase = z.object({ id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'id must be lowercase kebab-case'), type: z.string().min(1), name: z.string().min(1), description: z.string().min(1), system_prompt: z.string().min(1), - dispatcher: DispatcherKindSchema, + runtime: RuntimeSchema.default('direct'), model: z.string().min(1), /** Which memory backend this agent reads/writes. V1 supports 'sqlite'; 'flashback' is wired for the stub. */ memory_backend: MemoryBackendSchema.default('sqlite'), tools_allowlist: z.array(z.string()).default([]), /** Agent ids this agent is allowed to ask_agent. Empty = cannot call any agent. */ can_call: z.array(z.string()).default([]), - /** Phase A (today): stored but not yet consumed. Phase B wires these into - * a new ritsu-agent runtime that uses an explicit provider + api key - * instead of the Claude Agent SDK's Max-plan session. NULL provider = - * legacy claude-sdk path (current default for all existing agents). */ - provider: z.enum(['anthropic', 'openai', 'openai-compat', 'litellm']).nullable().default(null), + /** Vendor/provider under the chosen runtime; the runtime decides which set + * is valid (DIRECT_PROVIDERS vs API_PROVIDERS — see the superRefine). */ + provider: z.string().min(1).default('claude'), api_key_ref: z.number().int().positive().nullable().default(null), /** Free-form provider opts: temperature, max_tokens, base_url override, etc. */ provider_options: z.record(z.string(), z.unknown()).default({}), @@ -60,6 +76,29 @@ export const AgentDefinitionSchema = z.object({ previous_saved_at: z.number().int().nullable().optional(), }); +/** Cross-field rules the runtime/provider split introduces. Kept out of the + * base object so the Patch schema can stay a plain .partial(). */ +function refineRuntimeProvider(def: { runtime: 'direct' | 'api'; provider: string; api_key_ref: number | null; provider_options: Record }, ctx: z.RefinementCtx): void { + if (def.runtime === 'direct') { + if (!(DIRECT_PROVIDERS as readonly string[]).includes(def.provider)) { + ctx.addIssue({ code: 'custom', path: ['provider'], message: `direct runtime supports: ${DIRECT_PROVIDERS.join(', ')}` }); + } + return; + } + if (!(API_PROVIDERS as readonly string[]).includes(def.provider)) { + ctx.addIssue({ code: 'custom', path: ['provider'], message: `api runtime supports: ${API_PROVIDERS.join(', ')}` }); + return; + } + if (def.api_key_ref === null && !KEYLESS_API_PROVIDERS.includes(def.provider)) { + ctx.addIssue({ code: 'custom', path: ['api_key_ref'], message: `provider '${def.provider}' requires an api_key_ref` }); + } + if (def.provider === 'custom' && typeof def.provider_options.base_url !== 'string') { + ctx.addIssue({ code: 'custom', path: ['provider_options'], message: "provider 'custom' requires provider_options.base_url" }); + } +} + +export const AgentDefinitionSchema = AgentDefinitionBase.superRefine(refineRuntimeProvider); + export type AgentDefinition = z.infer; /** Capabilities an AGENT may grant another agent via the agent-admin tools. @@ -79,7 +118,7 @@ export function assertGrantableCapabilities(caps: readonly string[] | undefined) } } -export const AgentDefinitionPatchSchema = AgentDefinitionSchema.partial().omit({ +export const AgentDefinitionPatchSchema = AgentDefinitionBase.partial().omit({ id: true, created_at: true, updated_at: true, diff --git a/src/admin/server.ts b/src/admin/server.ts index c009288..d135816 100644 --- a/src/admin/server.ts +++ b/src/admin/server.ts @@ -20,7 +20,8 @@ import { EMAIL_NS, EMAIL_SECRET_KEYS } from '../connectors/email.js'; import { FLASHBACK_NS, FLASHBACK_SECRET_KEYS } from '../memory/config.js'; import { TWITTER_NS, TWITTER_SECRET_KEYS } from '../connectors/twitter.js'; import { LINKEDIN_NS, LINKEDIN_SECRET_KEYS } from '../connectors/linkedin.js'; -import { LITELLM_NS, LITELLM_SECRET_KEYS } from '../model/litellm-dispatcher.js'; +import { LITELLM_NS, LITELLM_SECRET_KEYS } from '../model/ritsu-agent/client.js'; +import { runHealthChecks } from './health.js'; import { INGEST_NS, INGEST_SECRET_KEYS } from '../ingestion/extractors.js'; import type { ChannelStore } from '../channels/channel-store.js'; import type { ChannelRegistry } from '../channels/registry.js'; @@ -189,7 +190,10 @@ const SecretSetBody = z.object({ const TestPaneBody = z.object({ system_prompt: z.string().min(1), message: z.string().min(1), - dispatcher: z.enum(['claude-direct', 'litellm']), + runtime: z.enum(['direct', 'api']).default('direct'), + provider: z.string().default('claude'), + api_key_ref: z.number().int().positive().nullable().default(null), + provider_options: z.record(z.string(), z.unknown()).default({}), model: z.string().trim().min(1), tools_allowlist: z.array(z.string()).optional(), workspaces: z.array(z.object({ @@ -1011,7 +1015,8 @@ export function createAdminApp(deps: AdminDeps) { id: def.id, name: def.name, model: def.model, - dispatcher: def.dispatcher, + runtime: def.runtime, + provider: def.provider, enabled: !!def.enabled, active, last_activity_ts: lastUsedAt, @@ -1238,11 +1243,22 @@ export function createAdminApp(deps: AdminDeps) { created_at: Math.floor(Date.now() / 1000), })); const { buildDispatcher } = await import('../model/factory.js'); - const dispatcher = buildDispatcher(body.dispatcher, body.model, { + const dispatcher = buildDispatcher(body.runtime === 'api' ? 'ritsu-agent' : 'claude-direct', body.model, { cwd: ephemeralWs[0]?.path, tools: body.tools_allowlist ?? [], workspaces: ephemeralWs, secrets, + // Draft api-runtime agents test against their real provider — no + // built-in tools, no memory: pure "what would this prompt return." + ...(body.runtime === 'api' ? { + ritsuAgent: { + provider: body.provider as import('../model/ritsu-agent/types.js').RaProvider, + apiKeyRef: body.api_key_ref, + apiKeys: deps.apiKeys, + providerOptions: body.provider_options, + toolDeps: null, + }, + } : {}), }); const t0 = Date.now(); const resp = await dispatcher.chat({ @@ -1316,7 +1332,8 @@ export function createAdminApp(deps: AdminDeps) { name: seed.name, description: seed.description, system_prompt: seed.system_prompt, - dispatcher: seed.dispatcher ?? 'claude-direct', + runtime: seed.runtime ?? 'direct', + provider: seed.provider ?? 'claude', model: seed.model ?? 'claude-sonnet-4-6', tools_allowlist: seed.tools_allowlist ?? [], capabilities: seed.capabilities ?? [], @@ -1327,6 +1344,11 @@ export function createAdminApp(deps: AdminDeps) { res.status(201).json({ created: true, id: agentId }); }); + // ---- health (live connectivity checks) --------------------------------- + app.get('/admin/api/health', async (_req: Request, res: Response) => { + res.json(await runHealthChecks({ defStore, apiKeys: deps.apiKeys, secrets })); + }); + // ---- backups + export (data safety) ------------------------------------ app.get('/admin/api/backups', (_req: Request, res: Response) => { res.json({ backups: backup.listBackups(), dir: backup.dir() }); diff --git a/src/admin/ui.html b/src/admin/ui.html index 480e5c7..059ca93 100644 --- a/src/admin/ui.html +++ b/src/admin/ui.html @@ -157,11 +157,11 @@

in LinkedIn

Agents

-
+
+
+

Health

+

Live checks against everything ritsu is wired to: core runtime, one probe per stored provider key (their model-list endpoints — free), and each configured connector.

+
+ + +
+
loading…
+
+
+

Backups

diff --git a/src/agent-definition-store.ts b/src/agent-definition-store.ts index e90ae57..fff5434 100644 --- a/src/agent-definition-store.ts +++ b/src/agent-definition-store.ts @@ -22,12 +22,12 @@ interface Row { name: string; description: string; system_prompt: string; - dispatcher: 'claude-direct' | 'litellm'; + runtime: 'direct' | 'api'; model: string; memory_backend: 'sqlite' | 'flashback'; tools_allowlist: string; can_call: string | null; - provider: string | null; + provider: string; api_key_ref: number | null; provider_options: string | null; capabilities: string | null; @@ -49,7 +49,7 @@ function rowToDef(r: Row): AgentDefinition { name: r.name, description: r.description, system_prompt: r.system_prompt, - dispatcher: r.dispatcher, + runtime: r.runtime, model: r.model, memory_backend: r.memory_backend, tools_allowlist: JSON.parse(r.tools_allowlist) as string[], @@ -151,7 +151,7 @@ export class SqliteAgentDefinitionStore implements AgentDefinitionStore { function writeAgentDefRow(db: Db, validated: AgentDefinition): void { db.prepare( `INSERT INTO agent_definitions - (id, type, name, description, system_prompt, dispatcher, model, + (id, type, name, description, system_prompt, runtime, model, memory_backend, tools_allowlist, can_call, provider, api_key_ref, provider_options, capabilities, approval_tools, plugins, enabled, escalation_approvable, allow_monitor_read) @@ -171,7 +171,7 @@ function writeAgentDefRow(db: Db, validated: AgentDefinition): void { ELSE agent_definitions.previous_saved_at END, system_prompt = excluded.system_prompt, - dispatcher = excluded.dispatcher, + runtime = excluded.runtime, model = excluded.model, memory_backend = excluded.memory_backend, tools_allowlist = excluded.tools_allowlist, @@ -192,7 +192,7 @@ function writeAgentDefRow(db: Db, validated: AgentDefinition): void { validated.name, validated.description, validated.system_prompt, - validated.dispatcher, + validated.runtime, validated.model, validated.memory_backend, JSON.stringify(validated.tools_allowlist), @@ -294,12 +294,12 @@ export async function seedIfEmpty(store: AgentDefinitionStore): Promise { '2. Echo back what the user just said.', '3. Confirm the wiring works in one sentence.', ].join('\n'), - dispatcher: 'claude-direct', + runtime: 'direct', model: 'claude-sonnet-4-6', memory_backend: 'sqlite', tools_allowlist: [], can_call: [], - provider: null, + provider: 'claude', api_key_ref: null, provider_options: {}, capabilities: [], diff --git a/src/agent-host.ts b/src/agent-host.ts index 147f3c6..8fe4eaf 100644 --- a/src/agent-host.ts +++ b/src/agent-host.ts @@ -3,7 +3,8 @@ import { buildAgent } from './agents/registry.js'; import { buildDispatcher, type DispatcherOpts } from './model/factory.js'; import { SqliteMemoryStore, FlashbackMemoryStore, type MemoryStore } from './memory-store.js'; import type { ConversationStore } from './conversation-store.js'; -import type { ModelDispatcher } from './model/dispatcher.js'; +import type { DispatcherKind, ModelDispatcher } from './model/dispatcher.js'; +import type { RaProvider } from './model/ritsu-agent/types.js'; import type { AgentDefinition } from './admin/schema.js'; import type { AgentDefinitionStore } from './agent-definition-store.js'; import type { WorkspaceStore } from './workspace-store.js'; @@ -26,6 +27,14 @@ export type DispatcherFactory = ( opts: DispatcherOpts, ) => ModelDispatcher; +/** runtime 'api' → our loop; runtime 'direct' → the vendor dispatcher for + * that provider ('claude' today; chatgpt/gemini/grok as they ship). */ +export function dispatcherKindFor(def: AgentDefinition): DispatcherKind { + if (def.runtime === 'api') return 'ritsu-agent'; + if (def.provider === 'claude') return 'claude-direct'; + throw new Error(`direct runtime has no dispatcher for provider '${def.provider}' yet`); +} + /** * Owns the live map of agent instances. Reads definitions from the * AgentDefinitionStore at boot and rebuilds an instance whenever @@ -57,10 +66,7 @@ export class AgentHost { private readonly commsDenials: CommsDenialStore, private readonly dispatcherFactory: DispatcherFactory = (def, opts) => buildDispatcher( - // ritsu-agent runtime overrides def.dispatcher when both provider + - // api_key_ref are set. Falls back to def.dispatcher (claude-direct - // / litellm) otherwise — existing agents are unchanged. - def.provider && def.api_key_ref ? 'ritsu-agent' : def.dispatcher, + dispatcherKindFor(def), def.model, { ...opts, secrets: this.secrets }, ), @@ -96,7 +102,7 @@ export class AgentHost { const canMonitor = def.capabilities.includes('monitor_agents'); const canCrm = def.capabilities.includes('crm'); const canSocial = def.capabilities.includes('social'); - const isRitsuAgent = !!(def.provider && def.api_key_ref); + const isRitsuAgent = def.runtime === 'api'; // SECURITY: an agent that reads untrusted content (email bodies, social // mentions) must not have an UNGATED egress/persistence path, or a @@ -166,7 +172,7 @@ export class AgentHost { // exposed as native function-calls instead of MCP transport. The // dispatcher decides whether to use this (kind === 'ritsu-agent') or // the SDK MCP path (kind === 'claude-direct'). - const ritsuAgentToolDeps = def.provider && def.api_key_ref ? { + const ritsuAgentToolDeps = isRitsuAgent ? { agentId: def.id, memory, defStore: this.defStore, @@ -276,11 +282,11 @@ export class AgentHost { approvals: this.approvals, }, } : {}), - // Phase B: ritsu-agent runtime config. Only consumed when the - // factory picks 'ritsu-agent' kind (def.provider + def.api_key_ref set). - ...(def.provider && def.api_key_ref ? { + // api-runtime config. Only consumed when the factory picks the + // 'ritsu-agent' kind (def.runtime === 'api'). + ...(isRitsuAgent ? { ritsuAgent: { - provider: def.provider as 'openai' | 'openai-compat' | 'litellm', + provider: def.provider as RaProvider, apiKeyRef: def.api_key_ref, apiKeys: this.apiKeys, providerOptions: def.provider_options, @@ -295,13 +301,13 @@ export class AgentHost { ...(this.memoryService ? { memoryService: this.memoryService } : {}), }; this.agents.set(def.id, buildAgent(def, deps)); - const effectiveDispatcher = def.provider && def.api_key_ref ? 'ritsu-agent' : def.dispatcher; logger.info('agent.wired', { id: def.id, type: def.type, - dispatcher: effectiveDispatcher, + runtime: def.runtime, + dispatcher: dispatcherKindFor(def), model: def.model, - provider: def.provider ?? null, + provider: def.provider, memory_backend: def.memory_backend, workspace: cwd ?? null, tools_count: effectiveTools.length, diff --git a/src/auth/api-key-store.ts b/src/auth/api-key-store.ts index ea72120..565686e 100644 --- a/src/auth/api-key-store.ts +++ b/src/auth/api-key-store.ts @@ -13,7 +13,7 @@ import { logger } from '../util/log.js'; /** Providers we know about. Open list — adding a new one is a string * literal here + a dispatcher branch later. */ -export const API_KEY_PROVIDERS = ['anthropic', 'openai', 'openai-compat', 'litellm'] as const; +export const API_KEY_PROVIDERS = ['anthropic', 'openai', 'gemini', 'xai', 'openrouter', 'litellm', 'custom'] as const; export type ApiKeyProvider = (typeof API_KEY_PROVIDERS)[number]; /** Row shape returned to callers — never includes the decrypted key. */ diff --git a/src/db.ts b/src/db.ts index e3b781e..38b1078 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2,22 +2,34 @@ import { DatabaseSync } from 'node:sqlite'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; -const SCHEMA = ` -PRAGMA journal_mode = WAL; -PRAGMA foreign_keys = ON; - --- The authoritative agent record. Edited via /admin CRUD. AgentHost reads at --- boot and on hot-reload. id is a stable kebab-case string chosen at create. +/** + * The authoritative agent record. Edited via /admin CRUD. AgentHost reads at + * boot and on hot-reload. id is a stable kebab-case string chosen at create. + * runtime 'direct' = a vendor agent runtime riding a subscription (provider + * 'claude' today); runtime 'api' = ritsu's own loop against a metered model + * API (provider anthropic/openai/gemini/xai/openrouter/litellm/custom). + * Standalone const so the runtime/provider migration can rebuild into it. + */ +const AGENT_DEFINITIONS_DDL = ` CREATE TABLE IF NOT EXISTS agent_definitions ( id TEXT PRIMARY KEY, type TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, system_prompt TEXT NOT NULL, - dispatcher TEXT NOT NULL CHECK (dispatcher IN ('claude-direct','litellm')), + runtime TEXT NOT NULL DEFAULT 'direct' CHECK (runtime IN ('direct','api')), + provider TEXT NOT NULL DEFAULT 'claude', model TEXT NOT NULL, memory_backend TEXT NOT NULL DEFAULT 'sqlite' CHECK (memory_backend IN ('sqlite','flashback')), tools_allowlist TEXT NOT NULL DEFAULT '[]', + can_call TEXT NOT NULL DEFAULT '[]', + api_key_ref INTEGER, + provider_options TEXT NOT NULL DEFAULT '{}', + capabilities TEXT NOT NULL DEFAULT '[]', + approval_tools TEXT NOT NULL DEFAULT '[]', + plugins TEXT NOT NULL DEFAULT '[]', + escalation_approvable INTEGER NOT NULL DEFAULT 0, + allow_monitor_read INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), @@ -25,7 +37,13 @@ CREATE TABLE IF NOT EXISTS agent_definitions ( -- on upsert when system_prompt changes. The Revert button swaps these. previous_system_prompt TEXT, previous_saved_at INTEGER -); +);`; + +const SCHEMA = ` +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +${AGENT_DEFINITIONS_DDL} -- Long-term knowledge, supersede-not-delete. agent_id is a plain string; -- no FK so memories outlive their owning definition (recreating an agent @@ -466,6 +484,47 @@ function migrate(db: Db): void { // won't backfill columns — so add them here. addColumnIfMissing(db, 'plugin_registry', 'version', "TEXT NOT NULL DEFAULT ''"); addColumnIfMissing(db, 'plugin_registry', 'enabled', 'INTEGER NOT NULL DEFAULT 1'); + migrateAgentRuntime(db); +} + +/** + * One-shot rebuild to the runtime/provider shape. A legacy table is detected + * by its `dispatcher` column, which carried a NOT NULL CHECK — SQLite can't + * drop that in place, so the table is rebuilt and rows mapped: + * provider+api_key_ref set → api (openai-compat renamed openrouter; the + * base_url override keeps working either way), everything else → + * direct/claude. Runs after the addColumnIfMissing block so every column + * referenced in the copy exists on the legacy table. + */ +function migrateAgentRuntime(db: Db): void { + const cols = db.prepare('PRAGMA table_info(agent_definitions)').all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'dispatcher')) return; + const tx = db.transaction(() => { + db.exec('ALTER TABLE agent_definitions RENAME TO agent_definitions_legacy'); + db.exec(AGENT_DEFINITIONS_DDL); + db.exec(` + INSERT INTO agent_definitions + (id, type, name, description, system_prompt, runtime, provider, model, + memory_backend, tools_allowlist, can_call, api_key_ref, provider_options, + capabilities, approval_tools, plugins, escalation_approvable, + allow_monitor_read, enabled, created_at, updated_at, + previous_system_prompt, previous_saved_at) + SELECT id, type, name, description, system_prompt, + CASE WHEN provider IS NOT NULL AND api_key_ref IS NOT NULL THEN 'api' ELSE 'direct' END, + CASE + WHEN provider IS NOT NULL AND api_key_ref IS NOT NULL THEN + CASE WHEN provider = 'openai-compat' THEN 'openrouter' ELSE provider END + ELSE 'claude' + END, + model, memory_backend, tools_allowlist, + COALESCE(can_call, '[]'), api_key_ref, COALESCE(provider_options, '{}'), + COALESCE(capabilities, '[]'), COALESCE(approval_tools, '[]'), COALESCE(plugins, '[]'), + COALESCE(escalation_approvable, 0), COALESCE(allow_monitor_read, 0), + enabled, created_at, updated_at, previous_system_prompt, previous_saved_at + FROM agent_definitions_legacy`); + db.exec('DROP TABLE agent_definitions_legacy'); + }); + tx(); } /** API keys for the ritsu-agent runtime (Phase B). Stored AES-256-GCM diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 85aaf91..f4b0a18 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -4,7 +4,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; import type { Express, Request, Response } from 'express'; import { z } from 'zod'; -import { AgentDefinitionSchema, AgentDefinitionPatchSchema, DispatcherKindSchema, MemoryBackendSchema } from './admin/schema.js'; +import { AgentDefinitionSchema, AgentDefinitionPatchSchema, RuntimeSchema, MemoryBackendSchema, DIRECT_PROVIDERS, API_PROVIDERS } from './admin/schema.js'; import type { AgentHost } from './agent-host.js'; import type { MemoryStore } from './memory-store.js'; import type { AgentDefinitionStore } from './agent-definition-store.js'; @@ -92,7 +92,7 @@ export const TOOL_INFO: ReadonlyArray<{ name: string; summary: string; args: str { name: 'list_agents', summary: 'List all enabled agents and their basic metadata.', args: '(no args)' }, { name: 'ask_agent', summary: 'Send a message to an agent. Returns the reply. Pass conversation_id to continue a thread.', args: 'agent_id, message, conversation_id?' }, { name: 'read_agent_memory', summary: 'Read the most recent active memories for an agent.', args: 'agent_id, limit?' }, - { name: 'create_agent', summary: 'Create a new agent. Saved + wired live, immediately callable via ask_agent.', args: 'id, type, name, description, system_prompt, dispatcher, model, …' }, + { name: 'create_agent', summary: 'Create a new agent. Saved + wired live, immediately callable via ask_agent.', args: 'id, type, name, description, system_prompt, runtime, provider, model, …' }, { name: 'update_agent', summary: 'Update one or more fields on an existing agent.', args: 'agent_id, patch' }, { name: 'reload_agent', summary: 'Rebuild an agent\'s live instance from its DB row. Use after out-of-band changes.', args: 'agent_id' }, ]; @@ -390,14 +390,14 @@ function buildMcpServer(deps: CreateMcpServerDeps): McpServer { name: z.string().describe('Human-readable name.'), description: z.string().describe('Short description of what the agent does.'), system_prompt: z.string().describe('System prompt that defines the agent\'s persona and rules.'), - dispatcher: DispatcherKindSchema.describe('Which model dispatcher to use (claude-direct or litellm).'), - model: z.string().describe('Model name passed to the dispatcher (e.g. "claude-sonnet-4-6" or "ollama/llama3").'), + runtime: RuntimeSchema.default('direct').describe("Runtime tier: 'direct' rides a vendor agent runtime (provider 'claude'); 'api' runs ritsu's own loop against a metered model API."), + provider: z.string().default('claude').describe(`Provider under the runtime. direct: ${DIRECT_PROVIDERS.join('/')}. api: ${API_PROVIDERS.join('/')}.`), + model: z.string().describe('Model name passed to the provider (e.g. "claude-sonnet-4-6", "gpt-5.2", "gemini-2.5-flash").'), memory_backend: MemoryBackendSchema.default('sqlite').describe('Memory backend (sqlite for V1).'), tools_allowlist: z.array(z.string()).default([]).describe('Reserved for V2 tool gating; pass [].'), can_call: z.array(z.string()).default([]).describe('Agent ids this agent is allowed to ask_agent. Empty = no inter-agent calls.'), - provider: z.enum(['anthropic', 'openai', 'openai-compat', 'litellm']).nullable().default(null).describe('Phase A: stored but not yet consumed by the runtime; null = use the claude-direct dispatcher.'), - api_key_ref: z.number().int().positive().nullable().default(null).describe('Phase A: api_keys.id reference for the provider. Null until Phase B wires it.'), - provider_options: z.record(z.string(), z.unknown()).default({}).describe('Phase A: provider-specific options (temperature, max_tokens, etc).'), + api_key_ref: z.number().int().positive().nullable().default(null).describe('api_keys.id for api-runtime providers. Null for direct runtime and keyless litellm/custom endpoints.'), + provider_options: z.record(z.string(), z.unknown()).default({}).describe('Provider-specific options (temperature, max_tokens, base_url, etc).'), capabilities: z.array(z.enum(['manage_agents', 'monitor_agents'])).default([]) .describe('Per-agent capabilities. Empty by default.'), approval_tools: z.array(z.string()).default([]) @@ -440,7 +440,8 @@ function buildMcpServer(deps: CreateMcpServerDeps): McpServer { name: z.string().optional(), description: z.string().optional(), system_prompt: z.string().optional(), - dispatcher: DispatcherKindSchema.optional(), + runtime: RuntimeSchema.optional(), + provider: z.string().optional(), model: z.string().optional(), memory_backend: MemoryBackendSchema.optional(), tools_allowlist: z.array(z.string()).optional(), diff --git a/src/model/dispatcher.ts b/src/model/dispatcher.ts index 0e6e75a..caaa9f0 100644 --- a/src/model/dispatcher.ts +++ b/src/model/dispatcher.ts @@ -56,7 +56,9 @@ export interface ChatResponse { raw: unknown; } -export type DispatcherKind = 'claude-direct' | 'litellm' | 'ritsu-agent'; +/** direct runtime → a vendor dispatcher ('claude-direct' today, more as + * vendor runtimes ship); api runtime → 'ritsu-agent' (our loop). */ +export type DispatcherKind = 'claude-direct' | 'ritsu-agent'; export interface ModelDispatcher { readonly kind: DispatcherKind; diff --git a/src/model/factory.ts b/src/model/factory.ts index 9134175..e671dad 100644 --- a/src/model/factory.ts +++ b/src/model/factory.ts @@ -1,9 +1,8 @@ import { ClaudeDirectDispatcher, type ClaudeDirectOpts } from './claude-direct-dispatcher.js'; -import { LiteLLMDispatcher, LITELLM_NS } from './litellm-dispatcher.js'; +import { LITELLM_NS } from './ritsu-agent/client.js'; import { RitsuAgentDispatcher } from './ritsu-agent/dispatcher.js'; -import type { OpenAIProvider } from './ritsu-agent/openai-client.js'; import type { RaToolDeps } from '../tools/ritsu-agent/builtin.js'; -import type { RaProviderOptions } from './ritsu-agent/types.js'; +import type { RaProvider, RaProviderOptions } from './ritsu-agent/types.js'; import type { DispatcherKind, ModelDispatcher } from './dispatcher.js'; import type { Workspace } from '../workspace-store.js'; import type { MemoryStore } from '../memory-store.js'; @@ -64,8 +63,10 @@ export interface DispatcherOpts { * an OpenAI-compatible provider instead of the Claude Agent SDK. */ ritsuAgent?: { - provider: OpenAIProvider; - apiKeyRef: number; + provider: RaProvider; + /** Null = keyless provider (litellm/custom); the litellm case falls back + * to the SecretStore's proxy credentials. */ + apiKeyRef: number | null; apiKeys: ApiKeyStore; providerOptions?: RaProviderOptions; /** Built-in tool deps (memory + agent-comms). Null = no built-ins. */ @@ -84,11 +85,6 @@ export interface DispatcherOpts { export function buildDispatcher(kind: DispatcherKind, model: string, opts: DispatcherOpts = {}): ModelDispatcher { switch (kind) { case 'claude-direct': return new ClaudeDirectDispatcher(model, claudeOptsFrom(opts)); - case 'litellm': { - const baseUrl = opts.secrets?.get(LITELLM_NS, 'url')?.trim() || 'http://localhost:4000'; - const apiKey = opts.secrets?.get(LITELLM_NS, 'api_key')?.trim() || undefined; - return new LiteLLMDispatcher(model, baseUrl, apiKey); - } case 'ritsu-agent': return new RitsuAgentDispatcher(ritsuAgentOptsFrom(opts, model)); default: { const _exhaustive: never = kind; @@ -122,12 +118,22 @@ function ritsuAgentOptsFrom(opts: DispatcherOpts, defaultModel: string) { if (!opts.ritsuAgent) { throw new Error('ritsu-agent dispatcher requires opts.ritsuAgent (provider, apiKeyRef, apiKeys, toolDeps)'); } + // A keyless litellm agent inherits the proxy connection configured in the + // SecretStore (admin → connectors), so per-agent config stays optional. + let providerOptions = opts.ritsuAgent.providerOptions; + let fallbackApiKey: string | undefined; + if (opts.ritsuAgent.provider === 'litellm' && opts.ritsuAgent.apiKeyRef === null) { + const url = opts.secrets?.get(LITELLM_NS, 'url')?.trim(); + if (url && !providerOptions?.base_url) providerOptions = { ...providerOptions, base_url: url }; + fallbackApiKey = opts.secrets?.get(LITELLM_NS, 'api_key')?.trim() || undefined; + } return { provider: opts.ritsuAgent.provider, apiKeyRef: opts.ritsuAgent.apiKeyRef, apiKeys: opts.ritsuAgent.apiKeys, defaultModel, - providerOptions: opts.ritsuAgent.providerOptions, + providerOptions, + ...(fallbackApiKey !== undefined ? { fallbackApiKey } : {}), toolDeps: opts.ritsuAgent.toolDeps, // Same approval gate the claude-direct path gets — but here it's the // reliable enforcement point (our own loop, no SDK to bypass it). diff --git a/src/model/litellm-dispatcher.ts b/src/model/litellm-dispatcher.ts deleted file mode 100644 index 014c12d..0000000 --- a/src/model/litellm-dispatcher.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ChatRequest, ChatResponse, ModelDispatcher } from './dispatcher.js'; -import { logger } from '../util/log.js'; - -/** - * Talks to a local LiteLLM proxy over its OpenAI-compatible HTTP API. - * LiteLLM handles auth to the underlying provider. - */ -/** SecretStore namespace + keys for the LiteLLM proxy connection. */ -export const LITELLM_NS = 'litellm'; -export const LITELLM_SECRET_KEYS = ['url', 'api_key'] as const; - -export class LiteLLMDispatcher implements ModelDispatcher { - readonly kind = 'litellm' as const; - - constructor( - readonly defaultModel: string, - private readonly baseUrl: string, - private readonly apiKey?: string, - ) {} - - async chat(req: ChatRequest): Promise { - const body = { - model: req.model ?? this.defaultModel, - messages: req.messages, - temperature: req.temperature ?? 0.7, - max_tokens: req.max_tokens ?? 4096, - }; - - logger.debug('litellm.chat', { model: body.model, msg_count: body.messages.length }); - - const res = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`LiteLLM ${res.status}: ${text}`); - } - - const json = await res.json() as { - choices: Array<{ message: { content: string } }>; - model: string; - usage?: { prompt_tokens?: number; completion_tokens?: number }; - }; - - return { - content: json.choices[0]?.message?.content ?? '', - model: json.model, - usage: { - input_tokens: json.usage?.prompt_tokens, - output_tokens: json.usage?.completion_tokens, - }, - raw: json, - }; - } -} diff --git a/src/model/ritsu-agent/anthropic-client.ts b/src/model/ritsu-agent/anthropic-client.ts new file mode 100644 index 0000000..8376298 --- /dev/null +++ b/src/model/ritsu-agent/anthropic-client.ts @@ -0,0 +1,172 @@ +/** + * Anthropic provider client backed by the official @anthropic-ai/sdk, + * speaking the Messages API: system prompts via the `system` param, tools + * as input_schema declarations, tool traffic as tool_use/tool_result + * blocks, images as base64 source blocks. + * + * The Messages API requires max_tokens (default 4096 here) and expects all + * tool_result blocks for a parallel tool_use turn in the single next user + * message — adjacent user-role turns are merged for that reason. + */ +import Anthropic from '@anthropic-ai/sdk'; +import type { MessageCreateParamsNonStreaming, MessageParam, ContentBlockParam } from '@anthropic-ai/sdk/resources/messages'; +import type { RaClient, RaMessage, RaTool, RaCompletion, RaToolCall, RaProviderOptions } from './types.js'; +import { stripTrailingSlashes } from '../../util/path-utils.js'; + +const DEFAULT_MAX_TOKENS = 4096; + +export interface AnthropicClientOpts { + apiKey: string; + model: string; + providerOptions?: RaProviderOptions; + /** Injected by tests; defaults to the SDK's own fetch. */ + fetchImpl?: typeof fetch; +} + +export class AnthropicClient implements RaClient { + private readonly client: Anthropic; + private readonly model: string; + private readonly providerOptions: RaProviderOptions; + + constructor(opts: AnthropicClientOpts) { + this.client = new Anthropic({ + apiKey: opts.apiKey, + ...(opts.providerOptions?.base_url + ? { baseURL: stripTrailingSlashes(opts.providerOptions.base_url) } + : {}), + ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}), + }); + this.model = opts.model; + this.providerOptions = opts.providerOptions ?? {}; + } + + async chat(messages: RaMessage[], tools: RaTool[]): Promise { + const { system, turns } = toAnthropicMessages(messages); + const body: MessageCreateParamsNonStreaming = { + model: this.model, + max_tokens: this.providerOptions.max_tokens ?? DEFAULT_MAX_TOKENS, + messages: turns, + }; + if (system) body.system = system; + if (this.providerOptions.temperature !== undefined) body.temperature = this.providerOptions.temperature; + if (tools.length > 0) { + body.tools = tools.map(t => ({ + name: t.name, + description: t.description, + input_schema: t.parameters as Anthropic.Tool['input_schema'], + })); + } + + const res = await this.client.messages.create(body); + + const content = res.content + .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .map(b => b.text) + .join(''); + const toolCalls: RaToolCall[] = res.content + .filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use') + .map(b => ({ + id: b.id, + type: 'function' as const, + function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) }, + })); + + return { + content, + tool_calls: toolCalls, + model: res.model, + usage: { + prompt_tokens: res.usage.input_tokens, + completion_tokens: res.usage.output_tokens, + total_tokens: res.usage.input_tokens + res.usage.output_tokens, + }, + raw: res, + }; + } +} + +/** Translate the loop's transcript into Messages-API shape. Exported for + * tests. */ +export function toAnthropicMessages(messages: RaMessage[]): { system: string; turns: MessageParam[] } { + const systemParts: string[] = []; + const turns: MessageParam[] = []; + + for (const m of messages) { + if (m.role === 'system') { + const t = textOf(m.content); + if (t) systemParts.push(t); + continue; + } + if (m.role === 'assistant') { + const blocks: ContentBlockParam[] = []; + const t = textOf(m.content); + if (t) blocks.push({ type: 'text', text: t }); + for (const call of m.tool_calls ?? []) { + blocks.push({ + type: 'tool_use', + id: call.id, + name: call.function.name, + input: parseArgs(call.function.arguments), + }); + } + if (blocks.length > 0) turns.push({ role: 'assistant', content: blocks }); + continue; + } + if (m.role === 'tool') { + appendUserBlocks(turns, [{ + type: 'tool_result', + tool_use_id: m.tool_call_id ?? '', + content: textOf(m.content), + }]); + continue; + } + appendUserBlocks(turns, userBlocks(m.content)); + } + + return { system: systemParts.join('\n\n'), turns }; +} + +/** Append blocks to the trailing user turn, or start a new one. Merging + * adjacent user turns is what puts parallel tool_results into the single + * next user message the API requires. */ +function appendUserBlocks(turns: MessageParam[], blocks: ContentBlockParam[]): void { + if (blocks.length === 0) return; + const last = turns[turns.length - 1]; + if (last?.role === 'user' && Array.isArray(last.content)) { + last.content.push(...blocks); + return; + } + turns.push({ role: 'user', content: blocks }); +} + +function userBlocks(content: RaMessage['content']): ContentBlockParam[] { + if (typeof content === 'string') { + return content ? [{ type: 'text', text: content }] : []; + } + const blocks: ContentBlockParam[] = []; + for (const b of content) { + if (b.type === 'text') { + if (b.text) blocks.push({ type: 'text', text: b.text }); + } else { + blocks.push({ + type: 'image', + source: { type: 'base64', media_type: b.media_type as Anthropic.Base64ImageSource['media_type'], data: b.data }, + }); + } + } + return blocks; +} + +function textOf(content: RaMessage['content']): string { + if (typeof content === 'string') return content; + return content.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map(b => b.text).join(''); +} + +function parseArgs(raw: string): Record { + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} diff --git a/src/model/ritsu-agent/client.ts b/src/model/ritsu-agent/client.ts new file mode 100644 index 0000000..0e6ad3c --- /dev/null +++ b/src/model/ritsu-agent/client.ts @@ -0,0 +1,43 @@ +/** + * Provider → client selection for the api runtime. First-party providers + * get their official SDK client; everything else shares the minimal + * OpenAI-compatible wire client. + */ +import type { RaClient, RaProvider, RaProviderOptions } from './types.js'; +import { OpenAICompatClient } from './openai-client.js'; +import { OpenAISdkClient } from './openai-sdk-client.js'; +import { AnthropicClient } from './anthropic-client.js'; +import { GeminiClient } from './gemini-client.js'; + +/** SecretStore namespace + keys for the LiteLLM proxy connection. The + * factory resolves these into base_url / key fallbacks for keyless + * litellm agents. */ +export const LITELLM_NS = 'litellm'; +export const LITELLM_SECRET_KEYS = ['url', 'api_key'] as const; + +export interface RaClientOpts { + provider: RaProvider; + apiKey: string; + model: string; + providerOptions?: RaProviderOptions; + /** Injected by tests; honored by every client except gemini — that SDK + * has no fetch seam, so its tests inject generateContentImpl instead. */ + fetchImpl?: typeof fetch; +} + +export function buildRaClient(opts: RaClientOpts): RaClient { + switch (opts.provider) { + case 'anthropic': return new AnthropicClient(opts); + case 'openai': return new OpenAISdkClient(opts); + case 'gemini': return new GeminiClient(opts); + case 'xai': + case 'openrouter': + case 'litellm': + case 'custom': + return new OpenAICompatClient({ ...opts, provider: opts.provider }); + default: { + const _exhaustive: never = opts.provider; + throw new Error(`Unknown api-runtime provider: ${JSON.stringify(_exhaustive)}`); + } + } +} diff --git a/src/model/ritsu-agent/dispatcher.ts b/src/model/ritsu-agent/dispatcher.ts index 3c3a5d2..15cfcc7 100644 --- a/src/model/ritsu-agent/dispatcher.ts +++ b/src/model/ritsu-agent/dispatcher.ts @@ -15,9 +15,9 @@ import type { ChatRequest, ChatResponse, ModelDispatcher } from '../dispatcher.js'; import type { ApiKeyStore } from '../../auth/api-key-store.js'; import type { ApprovalStore } from '../../approval-store.js'; -import { OpenAICompatClient, type OpenAIProvider } from './openai-client.js'; +import { buildRaClient } from './client.js'; import { buildBuiltinTools, type RaToolDeps } from '../../tools/ritsu-agent/builtin.js'; -import type { RaMessage, RaTool, RaToolCall, RaProviderOptions } from './types.js'; +import type { RaMessage, RaProvider, RaTool, RaToolCall, RaProviderOptions } from './types.js'; import { logger } from '../../util/log.js'; /** A misbehaving model that keeps tool-calling without producing a final @@ -27,11 +27,15 @@ import { logger } from '../../util/log.js'; const MAX_TOOL_ROUNDS = 8; export interface RitsuAgentDispatcherOpts { - provider: OpenAIProvider; + provider: RaProvider; /** api_keys.id — looked up via apiKeys.reveal() right before the call so - * the plaintext lives in memory only for the duration of the request. */ - apiKeyRef: number; + * the plaintext lives in memory only for the duration of the request. + * Null = keyless (litellm/custom endpoints; schema-enforced). */ + apiKeyRef: number | null; apiKeys: ApiKeyStore; + /** Key to use when apiKeyRef is null (e.g. the LiteLLM proxy key from the + * SecretStore). Empty/omitted = no Authorization header. */ + fallbackApiKey?: string; /** Default model; can be overridden per ChatRequest. */ defaultModel: string; providerOptions?: RaProviderOptions; @@ -62,13 +66,17 @@ export class RitsuAgentDispatcher implements ModelDispatcher { } async chat(req: ChatRequest): Promise { - const revealed = this.opts.apiKeys.reveal(this.opts.apiKeyRef); - if (!revealed) { - throw new Error(`api key ref=${this.opts.apiKeyRef} not found or revoked`); + let apiKey = this.opts.fallbackApiKey ?? ''; + if (this.opts.apiKeyRef !== null) { + const revealed = this.opts.apiKeys.reveal(this.opts.apiKeyRef); + if (!revealed) { + throw new Error(`api key ref=${this.opts.apiKeyRef} not found or revoked`); + } + apiKey = revealed.plaintext; } - const client = new OpenAICompatClient({ + const client = buildRaClient({ provider: this.opts.provider, - apiKey: revealed.plaintext, + apiKey, model: req.model ?? this.defaultModel, providerOptions: this.opts.providerOptions, fetchImpl: this.opts.fetchImpl, diff --git a/src/model/ritsu-agent/gemini-client.ts b/src/model/ritsu-agent/gemini-client.ts new file mode 100644 index 0000000..65fe1f7 --- /dev/null +++ b/src/model/ritsu-agent/gemini-client.ts @@ -0,0 +1,192 @@ +/** + * Gemini provider client backed by the official @google/genai SDK, speaking + * the native generateContent API (not Google's OpenAI-compat shim): system + * prompts via systemInstruction, tools as functionDeclarations carrying raw + * JSON Schema (parametersJsonSchema), images as inlineData parts. + * + * Gemini's FunctionCall may omit an id; the loop needs one to pair tool + * results with calls, so absent ids are synthesized (`gcall_*`). Synthesized + * ids are never echoed back in functionResponse parts — Gemini pairs by + * name/order; only ids the API itself issued are returned to it. + */ +import { GoogleGenAI } from '@google/genai'; +import type { Content, GenerateContentConfig, GenerateContentParameters, GenerateContentResponse, Part } from '@google/genai'; +import type { RaClient, RaMessage, RaTool, RaCompletion, RaToolCall, RaProviderOptions } from './types.js'; +import { stripTrailingSlashes } from '../../util/path-utils.js'; + +const SYNTH_ID_PREFIX = 'gcall_'; + +export interface GeminiClientOpts { + apiKey: string; + model: string; + providerOptions?: RaProviderOptions; + /** Injected by tests; defaults to the live SDK call. */ + generateContentImpl?: (req: GenerateContentParameters) => Promise; +} + +export class GeminiClient implements RaClient { + private readonly model: string; + private readonly providerOptions: RaProviderOptions | undefined; + private readonly generate: (req: GenerateContentParameters) => Promise; + + constructor(opts: GeminiClientOpts) { + this.model = opts.model; + this.providerOptions = opts.providerOptions; + if (opts.generateContentImpl) { + this.generate = opts.generateContentImpl; + } else { + const httpOptions = { + ...(opts.providerOptions?.base_url ? { baseUrl: stripTrailingSlashes(opts.providerOptions.base_url) } : {}), + ...(opts.providerOptions?.api_version ? { apiVersion: opts.providerOptions.api_version } : {}), + }; + const ai = new GoogleGenAI({ + apiKey: opts.apiKey, + ...(Object.keys(httpOptions).length > 0 ? { httpOptions } : {}), + }); + this.generate = req => ai.models.generateContent(req); + } + } + + async chat(messages: RaMessage[], tools: RaTool[]): Promise { + const req = toGeminiRequest(this.model, messages, tools, this.providerOptions); + const res = await this.generate(req); + return fromGeminiResponse(res, this.model); + } +} + +/** Translate the loop's transcript + tool defs into a generateContent + * request. Exported for tests. */ +export function toGeminiRequest( + model: string, + messages: RaMessage[], + tools: RaTool[], + options?: RaProviderOptions, +): GenerateContentParameters { + const systemParts: string[] = []; + const contents: Content[] = []; + // tool_call id → function name, so role:'tool' results (which only carry + // the id) can be rendered as functionResponse parts (which need the name). + const callNames = new Map(); + + for (const m of messages) { + if (m.role === 'system') { + const t = textOf(m.content); + if (t) systemParts.push(t); + continue; + } + if (m.role === 'assistant') { + const parts: Part[] = []; + const t = textOf(m.content); + if (t) parts.push({ text: t }); + for (const call of m.tool_calls ?? []) { + callNames.set(call.id, call.function.name); + parts.push({ + functionCall: { + ...(call.id.startsWith(SYNTH_ID_PREFIX) ? {} : { id: call.id }), + name: call.function.name, + args: parseArgs(call.function.arguments), + }, + }); + } + if (parts.length > 0) contents.push({ role: 'model', parts }); + continue; + } + if (m.role === 'tool') { + const id = m.tool_call_id; + contents.push({ + role: 'user', + parts: [{ + functionResponse: { + ...(id && !id.startsWith(SYNTH_ID_PREFIX) ? { id } : {}), + name: (id ? callNames.get(id) : undefined) ?? 'unknown', + response: { output: textOf(m.content) }, + }, + }], + }); + continue; + } + const parts = userParts(m.content); + if (parts.length > 0) contents.push({ role: 'user', parts }); + } + + const config: GenerateContentConfig = {}; + if (systemParts.length > 0) config.systemInstruction = systemParts.join('\n\n'); + if (options?.temperature !== undefined) config.temperature = options.temperature; + if (options?.max_tokens !== undefined) config.maxOutputTokens = options.max_tokens; + if (tools.length > 0) { + config.tools = [{ + functionDeclarations: tools.map(t => ({ + name: t.name, + description: t.description, + parametersJsonSchema: t.parameters, + })), + }]; + } + + return Object.keys(config).length > 0 ? { model, contents, config } : { model, contents }; +} + +/** Translate a generateContent response back into the loop's shape. + * Exported for tests. */ +export function fromGeminiResponse(res: GenerateContentResponse, fallbackModel: string): RaCompletion { + const candidate = res.candidates?.[0]; + if (!candidate) { + const reason = res.promptFeedback?.blockReason; + throw new Error(reason ? `gemini blocked the prompt: ${reason}` : 'gemini returned no candidates'); + } + + const parts = candidate.content?.parts ?? []; + const content = parts + .filter(p => typeof p.text === 'string' && !p.thought) + .map(p => p.text) + .join(''); + + const toolCalls: RaToolCall[] = []; + for (const p of parts) { + const fc = p.functionCall; + if (!fc?.name) continue; + toolCalls.push({ + id: fc.id ?? `${SYNTH_ID_PREFIX}${toolCalls.length}_${fc.name}`, + type: 'function', + function: { name: fc.name, arguments: JSON.stringify(fc.args ?? {}) }, + }); + } + + const u = res.usageMetadata; + return { + content, + tool_calls: toolCalls, + model: res.modelVersion ?? fallbackModel, + usage: u + ? { prompt_tokens: u.promptTokenCount, completion_tokens: u.candidatesTokenCount, total_tokens: u.totalTokenCount } + : undefined, + raw: res, + }; +} + +function textOf(content: RaMessage['content']): string { + if (typeof content === 'string') return content; + return content.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map(b => b.text).join(''); +} + +function userParts(content: RaMessage['content']): Part[] { + if (typeof content === 'string') return content ? [{ text: content }] : []; + const parts: Part[] = []; + for (const b of content) { + if (b.type === 'text') { + if (b.text) parts.push({ text: b.text }); + } else { + parts.push({ inlineData: { mimeType: b.media_type, data: b.data } }); + } + } + return parts; +} + +function parseArgs(raw: string): Record { + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} diff --git a/src/model/ritsu-agent/openai-client.ts b/src/model/ritsu-agent/openai-client.ts index 854ce57..7f4bf9b 100644 --- a/src/model/ritsu-agent/openai-client.ts +++ b/src/model/ritsu-agent/openai-client.ts @@ -1,26 +1,28 @@ /** * Minimal OpenAI-compatible Chat Completions client. Speaks the standard - * POST /v1/chat/completions shape that every modern LLM provider has - * converged on: OpenAI, OpenRouter, Together, Groq, Anyscale, Mistral, - * Ollama (local), LiteLLM (proxy), and many more. The provider name on - * the agent definition (`openai`, `openai-compat`, `litellm`) selects a - * default base_url; everything else is identical. + * POST /v1/chat/completions shape that everything without a first-party + * JS SDK has converged on: xAI (their documented path), OpenRouter, + * Together, Groq, Ollama (local), LiteLLM (proxy), and many more. The + * provider name selects a default base_url; everything else is identical. + * First-party providers (`anthropic`, `openai`, `gemini`) use their + * official SDK clients instead. * * Tool calling: standard `tools: [{type: 'function', function: {...}}]` * input format; response includes `tool_calls` on the assistant message * when the model wants to invoke functions. We translate verbatim. */ -import type { RaMessage, RaTool, RaCompletion, RaToolCall, RaProviderOptions } from './types.js'; +import type { RaClient, RaMessage, RaTool, RaCompletion, RaToolCall, RaProviderOptions } from './types.js'; import { stripTrailingSlashes } from '../../util/path-utils.js'; -export type OpenAIProvider = 'openai' | 'openai-compat' | 'litellm'; +export type CompatProvider = 'xai' | 'openrouter' | 'litellm' | 'custom'; -/** Default base URLs per provider hint. `openai-compat` is a catch-all — - * the caller is expected to set base_url in provider_options. */ -const DEFAULT_BASE_URLS: Record = { - openai: 'https://api.openai.com/v1', - 'openai-compat': 'https://openrouter.ai/api/v1', // sane default; configurable +/** Default base URLs per provider hint. `custom` has none by design — + * the definition schema requires provider_options.base_url for it. */ +const DEFAULT_BASE_URLS: Record = { + xai: 'https://api.x.ai/v1', + openrouter: 'https://openrouter.ai/api/v1', litellm: 'http://localhost:4000/v1', + custom: '', }; interface OpenAIChoice { @@ -49,7 +51,7 @@ interface OpenAIResponse { } export interface OpenAIClientOpts { - provider: OpenAIProvider; + provider: CompatProvider; apiKey: string; model: string; providerOptions?: RaProviderOptions; @@ -57,7 +59,7 @@ export interface OpenAIClientOpts { fetchImpl?: typeof fetch; } -export class OpenAICompatClient { +export class OpenAICompatClient implements RaClient { private readonly baseUrl: string; private readonly apiKey: string; private readonly model: string; @@ -66,7 +68,9 @@ export class OpenAICompatClient { private readonly fetchImpl: typeof fetch; constructor(opts: OpenAIClientOpts) { - this.baseUrl = stripTrailingSlashes(opts.providerOptions?.base_url ?? DEFAULT_BASE_URLS[opts.provider]); + const baseUrl = opts.providerOptions?.base_url ?? DEFAULT_BASE_URLS[opts.provider]; + if (!baseUrl) throw new Error(`provider '${opts.provider}' requires provider_options.base_url`); + this.baseUrl = stripTrailingSlashes(baseUrl); this.apiKey = opts.apiKey; this.model = opts.model; this.temperature = opts.providerOptions?.temperature ?? 0.7; @@ -95,7 +99,8 @@ export class OpenAICompatClient { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, + // Keyless local proxies (litellm/custom) get no Authorization header. + ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), }, body: JSON.stringify(body), }); @@ -125,7 +130,7 @@ export class OpenAICompatClient { } } -function toOpenAIMessage(m: RaMessage): Record { +export function toOpenAIMessage(m: RaMessage): Record { const out: Record = { role: m.role, content: toOpenAIContent(m.content) }; if (m.tool_call_id) out.tool_call_id = m.tool_call_id; if (m.tool_calls && m.tool_calls.length > 0) out.tool_calls = m.tool_calls; diff --git a/src/model/ritsu-agent/openai-sdk-client.ts b/src/model/ritsu-agent/openai-sdk-client.ts new file mode 100644 index 0000000..bd6df47 --- /dev/null +++ b/src/model/ritsu-agent/openai-sdk-client.ts @@ -0,0 +1,86 @@ +/** + * OpenAI provider client backed by the official `openai` SDK (typed + * params, automatic retries, request-id surfacing). Only used for the + * `openai` provider hint — aggregators/proxies keep the minimal wire + * client in openai-client.ts. + * + * Parameter policy: temperature and max tokens are sent ONLY when set in + * provider_options. OpenAI's reasoning models (o-series, gpt-5 family) + * reject non-default temperature and require `max_completion_tokens`, so + * unconditional defaults would break them. + */ +import OpenAI from 'openai'; +import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'; +import type { RaClient, RaMessage, RaTool, RaCompletion, RaToolCall, RaProviderOptions } from './types.js'; +import { toOpenAIMessage } from './openai-client.js'; +import { stripTrailingSlashes } from '../../util/path-utils.js'; + +export interface OpenAISdkClientOpts { + apiKey: string; + model: string; + providerOptions?: RaProviderOptions; + /** Injected by tests; defaults to the SDK's own fetch. */ + fetchImpl?: typeof fetch; +} + +export class OpenAISdkClient implements RaClient { + private readonly client: OpenAI; + private readonly model: string; + private readonly providerOptions: RaProviderOptions; + + constructor(opts: OpenAISdkClientOpts) { + this.client = new OpenAI({ + apiKey: opts.apiKey, + ...(opts.providerOptions?.base_url + ? { baseURL: stripTrailingSlashes(opts.providerOptions.base_url) } + : {}), + ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}), + }); + this.model = opts.model; + this.providerOptions = opts.providerOptions ?? {}; + } + + async chat(messages: RaMessage[], tools: RaTool[]): Promise { + const body: ChatCompletionCreateParamsNonStreaming = { + model: this.model, + // We render the wire shape ourselves (shared with the compat client); + // cast into the SDK's param union. + messages: messages.map(toOpenAIMessage) as unknown as ChatCompletionCreateParamsNonStreaming['messages'], + }; + if (this.providerOptions.temperature !== undefined) body.temperature = this.providerOptions.temperature; + if (this.providerOptions.max_tokens !== undefined) body.max_completion_tokens = this.providerOptions.max_tokens; + if (tools.length > 0) { + body.tools = tools.map(t => ({ + type: 'function' as const, + function: { name: t.name, description: t.description, parameters: t.parameters }, + })); + body.tool_choice = 'auto'; + } + + const res = await this.client.chat.completions.create(body); + const choice = res.choices[0]; + if (!choice) throw new Error('openai returned no choices'); + + const toolCalls: RaToolCall[] = (choice.message.tool_calls ?? []) + .filter(tc => tc.type === 'function') + .map(tc => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function.name, arguments: tc.function.arguments }, + })); + + return { + content: choice.message.content ?? '', + tool_calls: toolCalls, + model: res.model, + usage: res.usage + ? { + prompt_tokens: res.usage.prompt_tokens, + completion_tokens: res.usage.completion_tokens, + total_tokens: res.usage.total_tokens, + } + : undefined, + raw: res, + }; + } +} diff --git a/src/model/ritsu-agent/types.ts b/src/model/ritsu-agent/types.ts index 5722003..bb18dad 100644 --- a/src/model/ritsu-agent/types.ts +++ b/src/model/ritsu-agent/types.ts @@ -8,6 +8,19 @@ export type Role = 'system' | 'user' | 'assistant' | 'tool'; +/** Model providers the api runtime can talk to. anthropic/openai/gemini use + * their official SDKs; xai's documented path is its OpenAI-compatible API; + * openrouter (aggregator), litellm (local proxy), and custom (any + * OpenAI-compatible base_url) share the minimal wire client. */ +export type RaProvider = 'anthropic' | 'openai' | 'gemini' | 'xai' | 'openrouter' | 'litellm' | 'custom'; + +/** One provider round-trip: current transcript + tool defs in, the + * assistant's reply (text and/or tool calls) out. Each provider client + * implements this; the dispatcher loop is provider-agnostic. */ +export interface RaClient { + chat(messages: RaMessage[], tools: RaTool[]): Promise; +} + /** A part of a multi-part message. Mirrors the dispatcher-level ChatContentBlock; * the OpenAI client renders images as `image_url` data-URLs. */ export type RaContentBlock = diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 66fe18a..7a9a57d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -100,7 +100,9 @@ export interface PluginAgentSeed { name: string; description: string; system_prompt: string; - dispatcher?: 'claude-direct' | 'litellm'; + runtime?: 'direct' | 'api'; + /** Provider under the runtime; defaults to 'claude' (direct). */ + provider?: string; model?: string; /** SDK built-in tools the agent may use (claude-direct). Usually none for a * read/answer domain assistant. */ diff --git a/src/tools/mcp-internal/agent-admin.ts b/src/tools/mcp-internal/agent-admin.ts index 3133f18..4d47965 100644 --- a/src/tools/mcp-internal/agent-admin.ts +++ b/src/tools/mcp-internal/agent-admin.ts @@ -23,7 +23,7 @@ import { z } from 'zod'; import { AgentDefinitionSchema, AgentDefinitionPatchSchema, - DispatcherKindSchema, + RuntimeSchema, MemoryBackendSchema, assertGrantableCapabilities, } from '../../admin/schema.js'; @@ -64,8 +64,9 @@ export function buildAgentAdminMcp(callerAgentId: string, deps: AgentAdminDeps) name: z.string().describe('Human-readable name.'), description: z.string().describe('Short description of what the agent does.'), system_prompt: z.string().describe("System prompt defining the agent's persona and rules."), - dispatcher: DispatcherKindSchema.describe('Which model dispatcher to use (claude-direct or litellm).'), - model: z.string().describe('Model name passed to the dispatcher.'), + runtime: RuntimeSchema.default('direct').describe("Runtime tier: 'direct' (vendor runtime, provider 'claude') or 'api' (ritsu loop against a metered provider)."), + provider: z.string().default('claude').describe('Provider under the runtime (direct: claude; api: anthropic/openai/gemini/xai/openrouter/litellm/custom).'), + model: z.string().describe('Model name passed to the provider.'), memory_backend: MemoryBackendSchema.default('sqlite'), tools_allowlist: z.array(z.string()).default([]) .describe('SDK tool names this agent may use (Read, Bash, etc.). Empty = no tools.'), @@ -101,7 +102,8 @@ export function buildAgentAdminMcp(callerAgentId: string, deps: AgentAdminDeps) name: z.string().optional(), description: z.string().optional(), system_prompt: z.string().optional(), - dispatcher: DispatcherKindSchema.optional(), + runtime: RuntimeSchema.optional(), + provider: z.string().optional(), model: z.string().optional(), memory_backend: MemoryBackendSchema.optional(), tools_allowlist: z.array(z.string()).optional(), diff --git a/src/tools/mcp-internal/agent-monitor.ts b/src/tools/mcp-internal/agent-monitor.ts index 6f8f2b6..51d1a13 100644 --- a/src/tools/mcp-internal/agent-monitor.ts +++ b/src/tools/mcp-internal/agent-monitor.ts @@ -82,7 +82,7 @@ export function buildAgentMonitorMcp(callerAgentId: string, deps: AgentMonitorDe : all .map(a => { const readable = a.allow_monitor_read || a.id === callerAgentId ? 'readable' : 'opaque'; - return `[${a.id}] ${a.name} (${a.enabled ? 'enabled' : 'disabled'}, ${a.dispatcher}/${a.model}, monitor:${readable}) — ${a.description}`; + return `[${a.id}] ${a.name} (${a.enabled ? 'enabled' : 'disabled'}, ${a.runtime}:${a.provider}/${a.model}, monitor:${readable}) — ${a.description}`; }) .join('\n'); logger.info('agent-monitor.list_agents', { by: callerAgentId, count: all.length }); diff --git a/src/tools/ritsu-agent/builtin.ts b/src/tools/ritsu-agent/builtin.ts index 867d027..5cd24a3 100644 --- a/src/tools/ritsu-agent/builtin.ts +++ b/src/tools/ritsu-agent/builtin.ts @@ -311,14 +311,15 @@ export function buildAgentAdminTools(deps: RaToolDeps): RaTool[] { parameters: { type: 'object', additionalProperties: false, - required: ['id', 'name', 'description', 'system_prompt', 'dispatcher', 'model'], + required: ['id', 'name', 'description', 'system_prompt', 'model'], properties: { id: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$', description: 'Stable kebab-case identifier.' }, type: { type: 'string', default: 'generic' }, name: { type: 'string' }, description: { type: 'string' }, system_prompt: { type: 'string' }, - dispatcher: { type: 'string', enum: ['claude-direct', 'litellm'] }, + runtime: { type: 'string', enum: ['direct', 'api'], default: 'direct', description: "Runtime tier: 'direct' (vendor runtime, plan) or 'api' (metered key)." }, + provider: { type: 'string', default: 'claude', description: 'Provider under the runtime (direct: claude; api: anthropic/openai/gemini/xai/openrouter/litellm/custom).' }, model: { type: 'string' }, memory_backend: { type: 'string', enum: ['sqlite', 'flashback'], default: 'sqlite' }, tools_allowlist: { type: 'array', items: { type: 'string' }, default: [] }, @@ -330,15 +331,16 @@ export function buildAgentAdminTools(deps: RaToolDeps): RaTool[] { handler: async (args) => { try { // JSON-schema defaults aren't applied at parse time — mirror what the - // Zod path on the MCP side gets by hand. Provider/api-key-ref start - // as null (claude-sdk default) unless caller overrides. + // Zod path on the MCP side gets by hand. Defaults are the direct/ + // claude runtime unless the caller overrides. const withDefaults = { type: 'generic', memory_backend: 'sqlite', tools_allowlist: [], can_call: [], capabilities: [], - provider: null, + runtime: 'direct', + provider: 'claude', api_key_ref: null, provider_options: {}, enabled: true, @@ -429,7 +431,7 @@ export function buildAgentMonitorTools(deps: RaToolDeps): RaTool[] { { name: 'agent_monitor_list_agents', description: - 'List every agent registered on this server (id, name, description, enabled, dispatcher). ' + + 'List every agent registered on this server (id, name, description, enabled, runtime/provider). ' + 'NOT filtered to your can_call allowlist — monitoring sees the whole swarm.', parameters: { type: 'object', additionalProperties: false, properties: {} }, handler: async () => { @@ -439,7 +441,7 @@ export function buildAgentMonitorTools(deps: RaToolDeps): RaTool[] { return all .map(a => { const readable = a.allow_monitor_read || a.id === agentId ? 'readable' : 'opaque'; - return `[${a.id}] ${a.name} (${a.enabled ? 'enabled' : 'disabled'}, ${a.dispatcher}/${a.model}, monitor:${readable}) — ${a.description}`; + return `[${a.id}] ${a.name} (${a.enabled ? 'enabled' : 'disabled'}, ${a.runtime}:${a.provider}/${a.model}, monitor:${readable}) — ${a.description}`; }) .join('\n'); },