chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+748
View File
@@ -0,0 +1,748 @@
# OmniRoute A2A Server
> **Agent-to-Agent Protocol v0.3** — Enables any AI agent to use OmniRoute as an intelligent routing agent via JSON-RPC 2.0.
The A2A Server exposes OmniRoute as a **first-class agent** that other agents can discover, delegate tasks to, and collaborate with using the [A2A Protocol](https://google.github.io/A2A/).
---
## Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ Orchestrator Agent │
│ (LangChain, CrewAI, AutoGen, Custom Agent) │
└──────────────────────┬───────────────────────────────────────────┘
│ 1. GET /.well-known/agent.json (discover)
│ 2. POST /a2a (JSON-RPC 2.0)
┌──────────────────────────────────────────────────────────────────┐
│ OmniRoute A2A Server │
│ ┌────────────────┐ ┌────────────────┐ ┌───────────────────┐ │
│ │ Task Manager │ │ Skill Engine │ │ SSE Streaming │ │
│ │ (lifecycle) │──│ (registry) │──│ (real-time) │ │
│ └────────────────┘ └────────┬───────┘ └───────────────────┘ │
│ │ │
│ Skills: │ │
│ ├─ smart-routing ──────────┤ ┌────────────────────────────┐ │
│ └─ quota-management ───────┘ │ Routing Decision Logger │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
▼ OmniRoute Gateway (internal)
/v1/chat/completions, /api/combos, /api/usage/quota
```
---
## Quick Start
### Agent Discovery
Every A2A-compatible agent exposes an **Agent Card** at `/.well-known/agent.json`:
```bash
curl http://localhost:20128/.well-known/agent.json
```
**Response:**
```json
{
"name": "OmniRoute",
"description": "Intelligent AI gateway with auto-routing across 50+ providers",
"url": "http://localhost:20128/a2a",
"version": "1.8.1",
"capabilities": {
"streaming": true,
"pushNotifications": false
},
"skills": [
{
"id": "smart-routing",
"name": "Smart Routing",
"description": "Routes prompts through OmniRoute intelligent pipeline",
"tags": ["routing", "llm", "multi-provider", "cost-optimization"],
"examples": [
"Write a hello world in Python",
"Explain quantum computing using the cheapest provider"
]
},
{
"id": "quota-management",
"name": "Quota Management",
"description": "Natural-language queries about provider quotas",
"tags": ["quota", "analytics", "cost"],
"examples": [
"Which provider has the most quota remaining?",
"Suggest a free combo for coding"
]
}
],
"authentication": {
"schemes": ["bearer"],
"apiKeyHeader": "Authorization"
}
}
```
---
## JSON-RPC 2.0 Methods
### `message/send` — Synchronous Execution
Send a message to a skill and receive the complete response.
```bash
curl -X POST http://localhost:20128/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/send",
"params": {
"skill": "smart-routing",
"messages": [{"role": "user", "content": "Write a Python hello world"}],
"metadata": {"model": "auto", "combo": "fast-coding"}
}
}'
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": "1",
"result": {
"task": { "id": "a1b2c3d4-...", "state": "completed" },
"artifacts": [{ "type": "text", "content": "print('Hello, World!')" }],
"metadata": {
"routing_explanation": "Selected claude-sonnet via provider \"anthropic\" (latency: 1200ms, cost: $0.0030)",
"cost_envelope": { "estimated": 0.005, "actual": 0.003, "currency": "USD" },
"resilience_trace": [
{ "event": "primary_selected", "provider": "anthropic", "timestamp": "2026-03-04T..." }
],
"policy_verdict": { "allowed": true, "reason": "within budget and quota limits" }
}
}
}
```
### `message/stream` — SSE Streaming
Same as `message/send` but returns Server-Sent Events for real-time streaming.
```bash
curl -N -X POST http://localhost:20128/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/stream",
"params": {
"skill": "smart-routing",
"messages": [{"role": "user", "content": "Explain quantum computing"}]
}
}'
```
**SSE Events:**
```
data: {"jsonrpc":"2.0","method":"message/stream","params":{"task":{"id":"...","state":"working"},"chunk":{"type":"text","content":"Quantum computing..."}}}
: heartbeat 2026-03-04T21:00:00Z
data: {"jsonrpc":"2.0","method":"message/stream","params":{"task":{"id":"...","state":"completed"},"metadata":{...}}}
```
### `tasks/get` — Query Task Status
```bash
curl -X POST http://localhost:20128/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{"jsonrpc":"2.0","id":"2","method":"tasks/get","params":{"taskId":"TASK_UUID"}}'
```
### `tasks/cancel` — Cancel a Running Task
```bash
curl -X POST http://localhost:20128/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{"jsonrpc":"2.0","id":"3","method":"tasks/cancel","params":{"taskId":"TASK_UUID"}}'
```
---
## Skills Reference
### `smart-routing`
Routes prompts through OmniRoute's intelligent pipeline with full observability.
**Parameters (in `metadata`):**
| Parameter | Type | Default | Description |
| --------- | -------- | ------------ | ---------------------------------------------------------------------------------------- |
| `model` | `string` | `"auto"` | Target model (e.g., `claude-sonnet-4`, `gpt-4o`, `auto`) |
| `combo` | `string` | active combo | Specific combo to route through |
| `budget` | `number` | none | Maximum cost in USD for this request |
| `role` | `string` | none | Task role hint: `coding`, `review`, `planning`, `analysis`, `debugging`, `documentation` |
**Returns:**
| Field | Description |
| ------------------------------ | --------------------------------------------------------- |
| `artifacts[].content` | The LLM response text |
| `metadata.routing_explanation` | Human-readable explanation of routing decision |
| `metadata.cost_envelope` | Estimated vs actual cost with currency |
| `metadata.resilience_trace` | Array of events (primary_selected, fallback_needed, etc.) |
| `metadata.policy_verdict` | Whether the request was allowed and why |
### `quota-management`
Answers natural-language queries about provider quotas.
**Query types (inferred from message content):**
| Query Pattern | Response Type |
| ---------------------------------------------- | -------------------------------------------------------- |
| Contains `"ranking"`, `"most quota"`, `"best"` | Providers ranked by remaining quota |
| Contains `"free"`, `"suggest"` | Lists free combos or suggests free-tier providers |
| Default | Full quota summary with warnings for low-quota providers |
---
## Task Lifecycle
```
submitted ──→ working ──→ completed
──→ failed
──────────→ cancelled
```
| State | Description |
| ----------- | ----------------------------------------------------- |
| `submitted` | Task created, queued for execution |
| `working` | Skill handler is executing |
| `completed` | Execution succeeded, artifacts available |
| `failed` | Execution failed or task expired (TTL: 5 min default) |
| `cancelled` | Cancelled by client via `tasks/cancel` |
- Terminal states: `completed`, `failed`, `cancelled` (no further transitions)
- Expired tasks in `submitted` or `working` are auto-marked as `failed`
- Tasks are garbage-collected after 2× TTL
---
## Client Examples
### Python — Orchestrator Agent
```python
"""
A2A Client — Python example.
Discovers OmniRoute agent, sends a task, and processes the result.
"""
import requests
import json
BASE_URL = "http://localhost:20128"
API_KEY = "your-api-key"
HEADERS = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}",
}
# 1. Discover agent capabilities
agent_card = requests.get(f"{BASE_URL}/.well-known/agent.json").json()
print(f"Agent: {agent_card['name']} v{agent_card['version']}")
print(f"Skills: {[s['id'] for s in agent_card['skills']]}")
# 2. Send a smart-routing task
response = requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
"jsonrpc": "2.0",
"id": "task-1",
"method": "message/send",
"params": {
"skill": "smart-routing",
"messages": [{"role": "user", "content": "Write a Python quicksort implementation"}],
"metadata": {
"model": "auto",
"combo": "fast-coding",
"budget": 0.10,
}
}
})
result = response.json()["result"]
print(f"\n📝 Response: {result['artifacts'][0]['content'][:200]}...")
print(f"🔀 Routing: {result['metadata']['routing_explanation']}")
print(f"💰 Cost: ${result['metadata']['cost_envelope']['actual']}")
print(f"🛡️ Policy: {result['metadata']['policy_verdict']['reason']}")
# 3. Query quota status
quota_resp = requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
"jsonrpc": "2.0",
"id": "task-2",
"method": "message/send",
"params": {
"skill": "quota-management",
"messages": [{"role": "user", "content": "Which provider has the most quota remaining?"}],
}
})
quota_result = quota_resp.json()["result"]
print(f"\n📊 Quota: {quota_result['artifacts'][0]['content']}")
```
### TypeScript — Multi-Agent Orchestrator
```typescript
/**
* A2A Client — TypeScript example.
* Shows agent discovery, task delegation, and streaming.
*/
const BASE_URL = "http://localhost:20128";
const API_KEY = "your-api-key";
interface JsonRpcResponse<T = any> {
jsonrpc: "2.0";
id: string | number;
result?: T;
error?: { code: number; message: string };
}
async function a2aCall<T>(method: string, params: Record<string, any>): Promise<T> {
const resp = await fetch(`${BASE_URL}/a2a`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: `${method}-${Date.now()}`,
method,
params,
}),
});
const json: JsonRpcResponse<T> = await resp.json();
if (json.error) throw new Error(`[${json.error.code}] ${json.error.message}`);
return json.result!;
}
// ── Agent Discovery ──
const agentCard = await fetch(`${BASE_URL}/.well-known/agent.json`).then((r) => r.json());
console.log(`Connected to: ${agentCard.name} (${agentCard.skills.length} skills)`);
// ── Smart Routing: Send a coding task ──
const routingResult = await a2aCall("message/send", {
skill: "smart-routing",
messages: [{ role: "user", content: "Implement a Redis cache wrapper in TypeScript" }],
metadata: { model: "claude-sonnet-4", role: "coding" },
});
console.log("Response:", routingResult.artifacts[0].content);
console.log("Provider:", routingResult.metadata.routing_explanation);
// ── Quota Management: Find free alternatives ──
const quotaResult = await a2aCall("message/send", {
skill: "quota-management",
messages: [{ role: "user", content: "Suggest free combos for documentation" }],
});
console.log("Free combos:", quotaResult.artifacts[0].content);
// ── Streaming: Real-time response ──
const streamResp = await fetch(`${BASE_URL}/a2a`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "stream-1",
method: "message/stream",
params: {
skill: "smart-routing",
messages: [{ role: "user", content: "Explain microservices architecture" }],
},
}),
});
const reader = streamResp.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
for (const line of chunk.split("\n")) {
if (line.startsWith("data: ")) {
const event = JSON.parse(line.slice(6));
if (event.params.chunk) {
process.stdout.write(event.params.chunk.content);
}
if (event.params.task.state === "completed") {
console.log("\n✅ Stream completed");
}
}
}
}
```
### Python — LangChain A2A Integration
```python
"""
LangChain integration — Use OmniRoute A2A as a custom LLM.
"""
from langchain.llms.base import BaseLLM
from langchain.schema import LLMResult, Generation
import requests
from typing import List, Optional
class OmniRouteA2A(BaseLLM):
base_url: str = "http://localhost:20128"
api_key: str = ""
model: str = "auto"
combo: Optional[str] = None
@property
def _llm_type(self) -> str:
return "omniroute-a2a"
def _call(self, prompt: str, stop: Optional[List[str]] = None, **kwargs) -> str:
response = requests.post(
f"{self.base_url}/a2a",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
json={
"jsonrpc": "2.0",
"id": "langchain-1",
"method": "message/send",
"params": {
"skill": "smart-routing",
"messages": [{"role": "user", "content": prompt}],
"metadata": {
"model": self.model,
**({"combo": self.combo} if self.combo else {}),
},
},
},
)
result = response.json()["result"]
return result["artifacts"][0]["content"]
def _generate(self, prompts: List[str], stop=None, **kwargs) -> LLMResult:
return LLMResult(
generations=[[Generation(text=self._call(p, stop))] for p in prompts]
)
# Usage
llm = OmniRouteA2A(
base_url="http://localhost:20128",
api_key="your-key",
model="auto",
combo="fast-coding",
)
result = llm("Write a Python function to merge two sorted lists")
print(result)
```
### Go — A2A Client
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const baseURL = "http://localhost:20128"
const apiKey = "your-api-key"
type JsonRpcRequest struct {
Jsonrpc string `json:"jsonrpc"`
ID string `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params"`
}
type JsonRpcResponse struct {
Jsonrpc string `json:"jsonrpc"`
ID string `json:"id"`
Result interface{} `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func a2aCall(method string, params interface{}) (*JsonRpcResponse, error) {
body, _ := json.Marshal(JsonRpcRequest{
Jsonrpc: "2.0",
ID: "go-1",
Method: method,
Params: params,
})
req, _ := http.NewRequest("POST", baseURL+"/a2a", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var result JsonRpcResponse
json.Unmarshal(data, &result)
return &result, nil
}
func main() {
// Discover agent
resp, _ := http.Get(baseURL + "/.well-known/agent.json")
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println("Agent Card:", string(body))
// Send smart-routing task
result, _ := a2aCall("message/send", map[string]interface{}{
"skill": "smart-routing",
"messages": []map[string]string{{"role": "user", "content": "Hello from Go!"}},
"metadata": map[string]interface{}{"model": "auto"},
})
out, _ := json.MarshalIndent(result.Result, "", " ")
fmt.Println("Result:", string(out))
}
```
---
## Use Cases
### 🤖 Use Case 1: Multi-Agent Coding Pipeline
An orchestrator agent delegates code generation to OmniRoute, then passes the output to a review agent.
```python
def coding_pipeline(task: str):
# Step 1: Generate code via OmniRoute A2A
code_result = a2a_send("smart-routing", [
{"role": "user", "content": f"Write production-quality code: {task}"}
], metadata={"model": "auto", "role": "coding"})
code = code_result["artifacts"][0]["content"]
# Step 2: Review the code via OmniRoute A2A (different model)
review_result = a2a_send("smart-routing", [
{"role": "user", "content": f"Review this code for bugs and improvements:\n\n{code}"}
], metadata={"model": "auto", "role": "review"})
review = review_result["artifacts"][0]["content"]
# Step 3: Check costs
print(f"Code cost: ${code_result['metadata']['cost_envelope']['actual']}")
print(f"Review cost: ${review_result['metadata']['cost_envelope']['actual']}")
return {"code": code, "review": review}
```
### 💡 Use Case 2: Quota-Aware Agent Swarm
Multiple agents share quota through OmniRoute, using the quota skill to coordinate.
```python
async def quota_aware_agent(agent_name: str, task: str):
# Check quota before starting
quota = a2a_send("quota-management", [
{"role": "user", "content": "Which provider has the most quota remaining?"}
])
print(f"[{agent_name}] {quota['artifacts'][0]['content']}")
# Send request with budget constraint
result = a2a_send("smart-routing", [
{"role": "user", "content": task}
], metadata={"budget": 0.05})
policy = result["metadata"]["policy_verdict"]
if not policy["allowed"]:
print(f"[{agent_name}] ⚠️ Budget exceeded: {policy['reason']}")
# Fall back to free combo
quota = a2a_send("quota-management", [
{"role": "user", "content": "Suggest free combos"}
])
print(f"[{agent_name}] Free alternatives: {quota['artifacts'][0]['content']}")
return result
```
### 📊 Use Case 3: Real-Time Streaming Dashboard
A monitoring agent streams responses and displays progress in real-time.
```typescript
async function streamingDashboard(prompt: string) {
const response = await fetch(`${BASE_URL}/a2a`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify({
jsonrpc: "2.0",
id: "dash-1",
method: "message/stream",
params: { skill: "smart-routing", messages: [{ role: "user", content: prompt }] },
}),
});
let totalChunks = 0;
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (line.startsWith("data: ")) {
const event = JSON.parse(line.slice(6));
const state = event.params.task.state;
if (state === "working" && event.params.chunk) {
totalChunks++;
process.stdout.write(
`\r[Chunk ${totalChunks}] ${event.params.chunk.content.slice(0, 50)}...`
);
}
if (state === "completed") {
const meta = event.params.metadata;
console.log(
`\n✅ Done | Cost: $${meta?.cost_envelope?.actual || 0} | Route: ${meta?.routing_explanation || "N/A"}`
);
}
if (state === "failed") {
console.error(`\n❌ Failed: ${event.params.metadata?.error}`);
}
}
}
}
}
```
### 🔁 Use Case 4: Task Polling Pattern
For long-running tasks, poll the task status instead of waiting synchronously.
```python
import time
def poll_task(task_id: str, timeout: int = 60):
"""Poll task status until completion or timeout."""
start = time.time()
while time.time() - start < timeout:
result = requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
"jsonrpc": "2.0",
"id": "poll-1",
"method": "tasks/get",
"params": {"taskId": task_id},
}).json()
task = result["result"]["task"]
state = task["state"]
print(f" Task {task_id[:8]}... state={state}")
if state in ("completed", "failed", "cancelled"):
return task
time.sleep(2)
# Timeout — cancel the task
requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
"jsonrpc": "2.0",
"id": "cancel-1",
"method": "tasks/cancel",
"params": {"taskId": task_id},
})
raise TimeoutError(f"Task {task_id} timed out after {timeout}s")
```
---
## Error Codes
| Code | Constant | Meaning |
| ------ | ------------------------ | ---------------------------------------- |
| -32700 | — | Parse error (invalid JSON) |
| -32600 | `INVALID_REQUEST` | Invalid JSON-RPC request or unauthorized |
| -32601 | `METHOD_NOT_FOUND` | Unknown method or skill |
| -32602 | `INVALID_PARAMS` | Missing or invalid parameters |
| -32603 | `INTERNAL_ERROR` | Skill execution failed |
| -32001 | `TASK_NOT_FOUND` | Task ID not found |
| -32002 | `TASK_ALREADY_COMPLETED` | Cannot modify a completed task |
| -32003 | `UNAUTHORIZED` | Invalid or missing API key |
| -32004 | `BUDGET_EXCEEDED` | Request exceeds configured budget |
| -32005 | `PROVIDER_UNAVAILABLE` | No available providers |
---
## Authentication
All `/a2a` requests require a Bearer token via the `Authorization` header:
```
Authorization: Bearer YOUR_OMNIROUTE_API_KEY
```
If no API key is configured on the server (`OMNIROUTE_API_KEY` is empty), authentication is bypassed.
---
## File Structure
```
src/lib/a2a/
├── taskManager.ts # Task lifecycle (create/update/cancel/list), TTL, cleanup
├── taskExecution.ts # Generic task executor with state management
├── streaming.ts # SSE stream formatting, heartbeat, chunk/completion events
├── routingLogger.ts # Routing decision logger (stats, history, retention)
└── skills/
├── smartRouting.ts # Smart routing skill (routes via /v1/chat/completions)
└── quotaManagement.ts # Quota management skill (natural-language quota queries)
src/app/a2a/
└── route.ts # Next.js API route handler (JSON-RPC 2.0 dispatch)
open-sse/mcp-server/
└── schemas/a2a.ts # Zod schemas (AgentCard, Task, JSON-RPC, SSE events)
```
---
## Comparison: MCP vs A2A
| Feature | MCP Server | A2A Server |
| ----------------- | ---------------------------- | ------------------------------------------------- |
| **Protocol** | Model Context Protocol | Agent-to-Agent Protocol v0.3 |
| **Transport** | stdio / HTTP | HTTP (JSON-RPC 2.0) |
| **Discovery** | Tool listing via MCP | `/.well-known/agent.json` |
| **Granularity** | 16 individual tools | 2 high-level skills |
| **Best for** | IDE agents (Cursor, VS Code) | Multi-agent systems (LangChain, CrewAI) |
| **Streaming** | Not supported | SSE via `message/stream` |
| **Task tracking** | No | Full lifecycle (submitted → completed) |
| **Observability** | Audit log per tool call | Cost envelope + resilience trace + policy verdict |
---
## License
Part of [OmniRoute](https://github.com/diegosouzapw/OmniRoute) — MIT License.
+67
View File
@@ -0,0 +1,67 @@
/**
* A2A Routing Decision Logger
*
* Records every routing decision to the `routing_decisions` SQLite table.
* Used by `omniroute_explain_route` (T03) and future learning router.
* Retention: 7 days default.
*/
import { randomUUID } from "crypto";
export interface RoutingFactor {
name: string; // "quota", "health", "cost", "latency", "task_fit"
value: number;
weight: number;
contribution: number;
}
export interface FallbackEntry {
provider: string;
reason: string; // "circuit_breaker_open", "quota_exceeded", "timeout"
}
export interface RoutingDecision {
requestId: string;
taskType: string;
comboId: string;
providerSelected: string;
modelUsed: string;
score: number;
factors: RoutingFactor[];
fallbacksTriggered: FallbackEntry[];
success: boolean;
latencyMs: number;
cost: number;
timestamp: string;
}
// In-memory log (production would use SQLite via routing_decisions table)
const decisions: RoutingDecision[] = [];
const MAX_DECISIONS = 1000;
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Log a routing decision.
*/
export function logRoutingDecision(
params: Omit<RoutingDecision, "requestId" | "timestamp">
): RoutingDecision {
const decision: RoutingDecision = {
...params,
requestId: randomUUID(),
timestamp: new Date().toISOString(),
};
decisions.push(decision);
// Cleanup: cap + TTL
if (decisions.length > MAX_DECISIONS) {
const cutoff = new Date(Date.now() - RETENTION_MS);
const validIdx = decisions.findIndex((d) => new Date(d.timestamp) > cutoff);
if (validIdx > 0) decisions.splice(0, validIdx);
else if (decisions.length > MAX_DECISIONS)
decisions.splice(0, decisions.length - MAX_DECISIONS);
}
return decision;
}
+162
View File
@@ -0,0 +1,162 @@
/**
* A2A Skill: Cost Analysis
*
* Summarizes usage cost, provider/model spend distribution, and savings opportunities.
*/
import type { A2ATask, TaskArtifact } from "../taskManager";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
import { formatCost } from "@/shared/utils/formatting";
type AnalyticsRecord = Record<string, unknown>;
type CostEntry = {
id: string;
requests: number;
cost: number;
tokens: number;
};
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
function detectRange(task: A2ATask): string {
const metadataRange = task.input.metadata?.range;
if (typeof metadataRange === "string" && metadataRange.trim()) return metadataRange;
const query = task.input.messages.at(-1)?.content?.toLowerCase() || "";
if (query.includes("today") || query.includes("24h")) return "1d";
if (query.includes("week") || query.includes("7d")) return "7d";
if (query.includes("quarter") || query.includes("90d")) return "90d";
if (query.includes("year") || query.includes("ytd")) return "ytd";
return "30d";
}
async function costFetch(path: string): Promise<AnalyticsRecord> {
const url = `${OMNIROUTE_BASE_URL}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
};
const response = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
if (!response.ok) {
throw new Error(`API [${response.status}]: ${await response.text().catch(() => "error")}`);
}
return response.json();
}
function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function toCostEntries(value: unknown): CostEntry[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value as Record<string, AnalyticsRecord>)
.map(([id, raw]) => ({
id,
requests: toNumber(raw.requests ?? raw.count ?? raw.totalRequests),
cost: toNumber(raw.cost ?? raw.totalCost),
tokens:
toNumber(raw.tokens ?? raw.totalTokens ?? raw.promptTokens) +
toNumber(raw.completionTokens),
}))
.sort((left, right) => right.cost - left.cost);
}
function buildSavings(
providerCosts: CostEntry[],
modelCosts: CostEntry[],
fallbackRatePct: number
) {
const suggestions: string[] = [];
const topProvider = providerCosts[0];
const topModel = modelCosts[0];
if (topProvider?.cost > 0) {
suggestions.push(
`Review ${topProvider.id}: it is the largest provider cost at ${formatCost(topProvider.cost)}.`
);
}
if (topModel?.cost > 0) {
suggestions.push(
`Check model ${topModel.id}: it is the largest model cost at ${formatCost(topModel.cost)}.`
);
}
if (fallbackRatePct > 10) {
suggestions.push(
`Fallback rate is ${fallbackRatePct.toFixed(1)}%; tune combo priority or quota strategy to avoid expensive fallback paths.`
);
}
if (suggestions.length === 0) {
suggestions.push("No obvious cost-saving opportunity was detected in this range.");
}
return suggestions;
}
export interface CostAnalysisResult {
artifacts: TaskArtifact[];
metadata: {
range: string;
totalCost: number;
totalRequests: number;
providerCosts: CostEntry[];
modelCosts: CostEntry[];
savings: string[];
};
}
export async function executeCostAnalysis(task: A2ATask): Promise<CostAnalysisResult> {
const range = detectRange(task);
const analytics = await costFetch(
`/api/usage/analytics?range=${encodeURIComponent(range)}&presets=1d,7d,30d,90d,ytd`
);
const summary = (analytics.summary || {}) as AnalyticsRecord;
const providerCosts = toCostEntries(analytics.byProvider).slice(0, 10);
const modelCosts = toCostEntries(analytics.byModel).slice(0, 10);
const totalCost = toNumber(summary.totalCost);
const totalRequests = toNumber(summary.totalRequests ?? summary.requests);
const fallbackRatePct = toNumber(summary.fallbackRatePct);
const savings = buildSavings(providerCosts, modelCosts, fallbackRatePct);
return {
artifacts: [
{
type: "text",
content: [
`Cost analysis for range: ${range}`,
`Total cost: ${formatCost(totalCost)}`,
`Total requests: ${totalRequests.toLocaleString()}`,
`Fallback rate: ${fallbackRatePct.toFixed(2)}%`,
"",
"Top providers:",
...(providerCosts.length
? providerCosts
.slice(0, 5)
.map(
(entry, index) =>
`${index + 1}. ${entry.id} - ${formatCost(entry.cost)} (${entry.requests.toLocaleString()} requests)`
)
: ["No provider cost data available."]),
"",
"Savings opportunities:",
...savings.map((suggestion) => `- ${suggestion}`),
].join("\n"),
},
],
metadata: {
range,
totalCost,
totalRequests,
providerCosts,
modelCosts,
savings,
},
};
}
+155
View File
@@ -0,0 +1,155 @@
/**
* A2A Skill: Health Report
*
* Produces a structured health summary for orchestrating agents.
*/
import type { A2ATask, TaskArtifact } from "../taskManager";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
type JsonRecord = Record<string, unknown>;
type ProviderHealthEntry = {
state?: string;
failures?: number;
retryAfterMs?: number;
lastFailure?: string | null;
};
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
async function healthFetch(path: string): Promise<JsonRecord> {
const url = `${OMNIROUTE_BASE_URL}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
};
const response = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
if (!response.ok) {
throw new Error(`API [${response.status}]: ${await response.text().catch(() => "error")}`);
}
return response.json();
}
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function summarizeProviderHealth(providerHealth: unknown) {
const entries = Object.entries(asRecord(providerHealth)).map(([provider, raw]) => ({
provider,
...(asRecord(raw) as ProviderHealthEntry),
}));
const degraded = entries.filter((entry) => entry.state && entry.state !== "CLOSED");
return {
total: entries.length,
healthy: entries.filter((entry) => entry.state === "CLOSED").length,
degraded,
};
}
function summarizeRateLimits(rateLimitStatus: unknown) {
return Object.entries(asRecord(rateLimitStatus))
.map(([key, raw]) => {
const status = asRecord(raw);
return {
key,
queued: toNumber(status.queued),
running: toNumber(status.running),
maxConcurrent: toNumber(status.maxConcurrent),
};
})
.filter((entry) => entry.queued > 0 || entry.running > 0)
.sort((left, right) => right.queued + right.running - (left.queued + left.running));
}
export interface HealthReportResult {
artifacts: TaskArtifact[];
metadata: {
status: string;
providerSummary: {
total: number;
healthy: number;
degradedCount: number;
};
degradedProviders: Array<ProviderHealthEntry & { provider: string }>;
activeRateLimits: Array<{
key: string;
queued: number;
running: number;
maxConcurrent: number;
}>;
lockoutCount: number;
telemetry: JsonRecord;
};
}
export async function executeHealthReport(_task: A2ATask): Promise<HealthReportResult> {
const [healthResult, telemetryResult] = await Promise.allSettled([
healthFetch("/api/monitoring/health"),
healthFetch("/api/telemetry/summary"),
]);
if (healthResult.status === "rejected") {
throw healthResult.reason;
}
const health = healthResult.value;
const telemetry = telemetryResult.status === "fulfilled" ? telemetryResult.value : {};
const providerSummary = summarizeProviderHealth(health.providerHealth);
const activeRateLimits = summarizeRateLimits(health.rateLimitStatus).slice(0, 10);
const lockouts = asRecord(health.lockouts);
const lockoutCount = Object.keys(lockouts).length;
const status =
providerSummary.degraded.length > 0 || activeRateLimits.length > 0 || lockoutCount > 0
? "degraded"
: String(health.status || "healthy");
const degradedLines =
providerSummary.degraded.length > 0
? providerSummary.degraded.slice(0, 8).map((entry) => {
const retry =
typeof entry.retryAfterMs === "number" && entry.retryAfterMs > 0
? `, retry in ${Math.round(entry.retryAfterMs / 1000)}s`
: "";
return `- ${entry.provider}: ${entry.state || "unknown"} (${entry.failures || 0} failures${retry})`;
})
: ["- No degraded providers."];
return {
artifacts: [
{
type: "text",
content: [
`Health report: ${status}`,
`Providers healthy: ${providerSummary.healthy}/${providerSummary.total}`,
`Active rate limit queues: ${activeRateLimits.length}`,
`Active lockouts: ${lockoutCount}`,
`Recent requests: ${toNumber(telemetry.totalRequests).toLocaleString()}`,
`p95 latency: ${Math.round(toNumber(telemetry.p95))}ms`,
"",
"Degraded providers:",
...degradedLines,
].join("\n"),
},
],
metadata: {
status,
providerSummary: {
total: providerSummary.total,
healthy: providerSummary.healthy,
degradedCount: providerSummary.degraded.length,
},
degradedProviders: providerSummary.degraded,
activeRateLimits,
lockoutCount,
telemetry,
},
};
}
+72
View File
@@ -0,0 +1,72 @@
/**
* A2A Skill: List Capabilities
*
* Returns the full catalog of OmniRoute agent skills (22 API + 20 CLI + config)
* as a markdown table with raw SKILL.md URLs for orchestrating agents.
*/
import type { A2ATask, TaskArtifact } from "../taskManager";
import { getCatalog, computeCoverage } from "@/lib/agentSkills/catalog";
import type { AgentSkill } from "@/lib/agentSkills/types";
export interface ListCapabilitiesResult {
artifacts: TaskArtifact[];
metadata: {
coverage: {
api: { have: number; total: 23 };
cli: { have: number; total: 20 };
};
totalSkills: number;
generatedAt: string;
source: "agent-skills-catalog";
};
}
function buildMarkdownTable(skills: AgentSkill[]): string {
const header = "| ID | Name | Category | Area | Endpoints/Commands | Raw URL |";
const separator = "| --- | --- | --- | --- | --- | --- |";
const rows = skills.map((skill) => {
const endpointsOrCommands =
skill.category === "api"
? (skill.endpoints ?? []).join(", ") || "—"
: (skill.cliCommands ?? []).join(", ") || "—";
return `| ${skill.id} | ${skill.name} | ${skill.category} | ${skill.area} | ${endpointsOrCommands} | ${skill.rawUrl} |`;
});
return [header, separator, ...rows].join("\n");
}
export async function executeListCapabilities(_task: A2ATask): Promise<ListCapabilitiesResult> {
const catalog = getCatalog();
const coverage = computeCoverage();
const table = buildMarkdownTable(catalog);
const content = [
`# OmniRoute Agent Skills Catalog`,
``,
`Total: ${catalog.length} skills (${coverage.api.total} API + ${coverage.cli.total} CLI)`,
``,
table,
].join("\n");
return {
artifacts: [
{
type: "text",
content,
},
],
metadata: {
coverage: {
api: { have: coverage.api.have, total: 23 },
cli: { have: coverage.cli.have, total: 20 },
},
totalSkills: catalog.length,
generatedAt: coverage.generatedAt,
source: "agent-skills-catalog",
},
};
}
+202
View File
@@ -0,0 +1,202 @@
/**
* A2A Skill: Provider Discovery
*
* Answers provider capability, availability, and routing-fit questions for agents.
*/
import type { A2ATask, TaskArtifact } from "../taskManager";
import {
AI_PROVIDERS,
AUDIO_ONLY_PROVIDERS,
EMBEDDING_RERANK_PROVIDER_IDS,
IMAGE_ONLY_PROVIDER_IDS,
LOCAL_PROVIDERS,
SEARCH_PROVIDERS,
VIDEO_PROVIDER_IDS,
} from "@/shared/constants/providers";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
type ProviderConnectionLike = {
id?: string;
provider?: string;
name?: string | null;
isActive?: boolean | null;
};
type CircuitBreakerLike = {
name?: string;
state?: string;
failureCount?: number;
retryAfterMs?: number;
};
type ProviderCandidate = {
id: string;
name: string;
capabilities: string[];
configured: boolean;
active: boolean;
health: "healthy" | "recovering" | "down" | "unknown";
modelCount: number;
};
function detectCapability(task: A2ATask): string {
const metadataCapability = task.input.metadata?.capability;
if (typeof metadataCapability === "string" && metadataCapability.trim()) {
return metadataCapability.toLowerCase();
}
const query = task.input.messages.at(-1)?.content?.toLowerCase() || "";
if (query.includes("image") || query.includes("vision")) return "images";
if (query.includes("video")) return "video";
if (query.includes("audio") || query.includes("transcription") || query.includes("speech")) {
return "audio";
}
if (query.includes("embed")) return "embeddings";
if (query.includes("rerank")) return "rerank";
if (query.includes("search") || query.includes("web")) return "search";
if (query.includes("local") || query.includes("self-hosted")) return "local";
return "chat";
}
function providerCapabilities(providerId: string): string[] {
const capabilities = new Set<string>();
const registryEntry = REGISTRY[providerId];
if (registryEntry) capabilities.add("chat");
if (IMAGE_ONLY_PROVIDER_IDS.has(providerId)) capabilities.add("images");
if (VIDEO_PROVIDER_IDS.has(providerId)) capabilities.add("video");
if (EMBEDDING_RERANK_PROVIDER_IDS.has(providerId)) {
capabilities.add("embeddings");
capabilities.add("rerank");
}
if (Object.prototype.hasOwnProperty.call(SEARCH_PROVIDERS, providerId)) {
capabilities.add("search");
}
if (Object.prototype.hasOwnProperty.call(AUDIO_ONLY_PROVIDERS, providerId)) {
capabilities.add("audio");
}
if (Object.prototype.hasOwnProperty.call(LOCAL_PROVIDERS, providerId)) {
capabilities.add("local");
capabilities.add("chat");
}
if (registryEntry?.models?.some((model) => model.supportsVision)) {
capabilities.add("vision");
}
return [...capabilities].sort();
}
function healthFromBreaker(breaker?: CircuitBreakerLike): ProviderCandidate["health"] {
if (!breaker?.state) return "unknown";
if (breaker.state === "CLOSED") return "healthy";
if (breaker.state === "HALF_OPEN") return "recovering";
return "down";
}
function scoreCandidate(candidate: ProviderCandidate, requestedCapability: string): number {
let score = 0;
if (candidate.capabilities.includes(requestedCapability)) score += 100;
if (requestedCapability === "chat" && candidate.capabilities.includes("chat")) score += 40;
if (candidate.active) score += 30;
if (candidate.configured) score += 20;
if (candidate.health === "healthy") score += 20;
if (candidate.health === "recovering") score += 5;
if (candidate.health === "down") score -= 100;
score += Math.min(candidate.modelCount, 25);
return score;
}
export interface ProviderDiscoveryResult {
artifacts: TaskArtifact[];
metadata: {
capability: string;
totalCandidates: number;
configuredCandidates: number;
recommendedProvider: string | null;
candidates: ProviderCandidate[];
};
}
export async function executeProviderDiscovery(task: A2ATask): Promise<ProviderDiscoveryResult> {
const [{ getProviderConnections }, { getAllCircuitBreakerStatuses }] = await Promise.all([
import("@/lib/localDb"),
import("@/shared/utils/circuitBreaker"),
]);
const requestedCapability = detectCapability(task);
const connections = ((await getProviderConnections().catch(() => [])) ||
[]) as ProviderConnectionLike[];
const activeProviders = new Set(
connections
.filter((connection) => connection.provider && connection.isActive !== false)
.map((connection) => connection.provider as string)
);
const configuredProviders = new Set(
connections
.filter((connection) => connection.provider)
.map((connection) => connection.provider as string)
);
const breakers = new Map(
getAllCircuitBreakerStatuses().map((breaker: CircuitBreakerLike) => [
breaker.name || "",
breaker,
])
);
const candidates = Object.entries(AI_PROVIDERS)
.map(([providerId, provider]) => {
const capabilities = providerCapabilities(providerId);
return {
id: providerId,
name: provider.name || providerId,
capabilities,
configured: configuredProviders.has(providerId),
active: activeProviders.has(providerId),
health: healthFromBreaker(breakers.get(providerId)),
modelCount: REGISTRY[providerId]?.models?.length || 0,
} satisfies ProviderCandidate;
})
.filter((candidate) =>
requestedCapability === "chat"
? candidate.capabilities.includes("chat")
: candidate.capabilities.includes(requestedCapability)
)
.sort(
(left, right) =>
scoreCandidate(right, requestedCapability) - scoreCandidate(left, requestedCapability)
);
const top = candidates.slice(0, 10);
const recommended = top[0] || null;
const configuredCount = candidates.filter((candidate) => candidate.configured).length;
return {
artifacts: [
{
type: "text",
content:
top.length > 0
? [
`Provider discovery for capability: ${requestedCapability}`,
recommended ? `Recommended provider: ${recommended.name} (${recommended.id})` : "",
"",
...top.map(
(candidate, index) =>
`${index + 1}. ${candidate.name} (${candidate.id}) - ${candidate.health}, ` +
`${candidate.configured ? "configured" : "not configured"}, ` +
`${candidate.modelCount} catalog models`
),
].join("\n")
: `No providers matched capability: ${requestedCapability}`,
},
],
metadata: {
capability: requestedCapability,
totalCandidates: candidates.length,
configuredCandidates: configuredCount,
recommendedProvider: recommended?.id || null,
candidates: top,
},
};
}
+121
View File
@@ -0,0 +1,121 @@
/**
* A2A Skill: Quota Management
*
* Handles natural-language queries about provider quotas and
* returns structured responses with actionable data.
*/
import type { A2ATask, TaskArtifact } from "../taskManager";
import { normalizeQuotaResponse } from "@/shared/contracts/quota";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
async function quotaFetch(path: string): Promise<any> {
const url = `${OMNIROUTE_BASE_URL}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
};
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
if (!res.ok) throw new Error(`API [${res.status}]`);
return res.json();
}
export interface QuotaManagementResult {
artifacts: TaskArtifact[];
metadata: Record<string, unknown>;
}
export async function executeQuotaManagement(task: A2ATask): Promise<QuotaManagementResult> {
const query = task.input.messages[task.input.messages.length - 1]?.content?.toLowerCase() || "";
const [quotaRaw, combosRaw] = await Promise.allSettled([
quotaFetch("/api/usage/quota"),
quotaFetch("/api/combos"),
]);
const quota =
quotaRaw.status === "fulfilled"
? normalizeQuotaResponse(quotaRaw.value)
: normalizeQuotaResponse({});
const combos =
combosRaw.status === "fulfilled"
? Array.isArray((combosRaw.value as any)?.combos)
? (combosRaw.value as any).combos
: Array.isArray(combosRaw.value)
? (combosRaw.value as any[])
: []
: [];
const providers = quota.providers;
const availableQuota = (provider: { quotaTotal: number | null; quotaUsed: number }) => {
if (provider.quotaTotal === null) return Number.POSITIVE_INFINITY;
return provider.quotaTotal - provider.quotaUsed;
};
// Query classification
if (query.includes("ranking") || query.includes("most quota") || query.includes("best")) {
const sorted = [...providers].sort((a, b) => availableQuota(b) - availableQuota(a));
return {
artifacts: [
{
type: "text",
content: `**Quota Ranking (most available first):**\n${sorted
.map((p, i) => {
const remaining = availableQuota(p);
const remainingLabel =
remaining === Number.POSITIVE_INFINITY ? "unlimited" : remaining.toLocaleString();
const percentLabel =
p.quotaTotal === null ? "n/a" : `${Math.round(p.percentRemaining)}%`;
return `${i + 1}. **${p.provider}** — ${remainingLabel} remaining (${percentLabel})`;
})
.join("\n")}`,
},
],
metadata: { queryType: "ranking", providers: sorted.map((p) => p.provider) },
};
}
if (query.includes("free") || query.includes("suggest")) {
const freeCombos = combos.filter((c: any) => {
const name = (c.name || "").toLowerCase();
return name.includes("free") || name.includes("gratis");
});
return {
artifacts: [
{
type: "text",
content:
freeCombos.length > 0
? `**Free combos available:**\n${freeCombos.map((c: any) => `- **${c.name}** (ID: ${c.id})`).join("\n")}`
: "No free combos configured. Consider adding providers with free tiers (Gemini, Groq, etc.).",
},
],
metadata: { queryType: "free_suggestion", freeCombos: freeCombos.length },
};
}
// Default: general quota summary
const totalUsed = providers.reduce((sum, p) => sum + (p.quotaUsed || 0), 0);
const totalAvailable = providers.reduce((sum, p) => sum + (p.quotaTotal || 0), 0);
const warnings = providers.filter((p) => p.percentRemaining <= 10);
return {
artifacts: [
{
type: "text",
content: `**Quota Summary (${providers.length} providers):**\n- Total used: ${totalUsed.toLocaleString()} / ${totalAvailable.toLocaleString()}\n${providers
.map(
(p) =>
`- **${p.provider}:** ${p.quotaUsed.toLocaleString()} / ${p.quotaTotal?.toLocaleString() || "∞"} (${p.tokenStatus})`
)
.join(
"\n"
)}${warnings.length > 0 ? `\n\n⚠️ **Warning:** ${warnings.map((w) => w.provider).join(", ")} at or below 10% remaining` : ""}`,
},
],
metadata: { queryType: "summary", providerCount: providers.length, warnings: warnings.length },
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* A2A Skill: Smart Routing
*
* Receives a prompt + metadata → routes via OmniRoute pipeline →
* returns response with routing_explanation, cost_envelope, resilience_trace, policy_verdict.
*/
import type { A2ATask, TaskArtifact } from "../taskManager";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
async function routeFetch(path: string, options: RequestInit = {}): Promise<any> {
const url = `${OMNIROUTE_BASE_URL}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
};
const res = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`API [${res.status}]: ${await res.text().catch(() => "error")}`);
return res.json();
}
export interface SmartRoutingResult {
artifacts: TaskArtifact[];
metadata: {
routing_explanation: string;
cost_envelope: { estimated: number; actual: number; currency: string };
resilience_trace: Array<{ event: string; provider: string; timestamp: string }>;
policy_verdict: { allowed: boolean; reason: string };
};
}
export async function executeSmartRouting(task: A2ATask): Promise<SmartRoutingResult> {
const messages = task.input.messages;
const model = (task.input.metadata?.model as string) || "auto";
const combo = task.input.metadata?.combo as string | undefined;
const budget = task.input.metadata?.budget as number | undefined;
const start = Date.now();
const body: Record<string, unknown> = { model, messages, stream: false };
if (combo) body["x-combo"] = combo;
const raw = await routeFetch("/v1/chat/completions", {
method: "POST",
body: JSON.stringify(body),
});
const latencyMs = Date.now() - start;
const content = raw?.choices?.[0]?.message?.content || "";
const provider = raw?.provider || "unknown";
const actualCost = raw?.cost || 0;
const promptTokens = raw?.usage?.prompt_tokens || 0;
const estimatedCost = (promptTokens / 1_000_000) * 3.0; // rough estimate
// Budget policy check
const withinBudget = budget ? actualCost <= budget : true;
return {
artifacts: [{ type: "text", content }],
metadata: {
routing_explanation: `Selected ${raw?.model || model} via provider "${provider}" (latency: ${latencyMs}ms, cost: $${actualCost.toFixed(4)})`,
cost_envelope: {
estimated: Math.round(estimatedCost * 10000) / 10000,
actual: Math.round(actualCost * 10000) / 10000,
currency: "USD",
},
resilience_trace: [
{ event: "primary_selected", provider, timestamp: new Date().toISOString() },
...(raw?.fallbacksTriggered
? [
{
event: "fallback_needed",
provider: "secondary",
timestamp: new Date().toISOString(),
},
]
: []),
],
policy_verdict: {
allowed: withinBudget,
reason: withinBudget
? "within budget and quota limits"
: `cost $${actualCost} exceeds budget $${budget}`,
},
},
};
}
+149
View File
@@ -0,0 +1,149 @@
/**
* A2A SSE Streaming Support
*
* Provides SSE event formatting for A2A `message/stream` responses.
* Features: heartbeat (15s), chunk emission, metadata final event, cancellation.
*/
import type { A2ATask } from "./taskManager";
export interface SSEChunkEvent {
jsonrpc: "2.0";
method: "message/stream";
params: {
task: { id: string; state: string };
chunk?: { type: string; content: string };
metadata?: Record<string, unknown>;
};
}
/**
* Format an SSE event line.
*/
export function formatSSE(event: SSEChunkEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
/**
* Create a chunk event for streaming text content.
*/
export function createChunkEvent(taskId: string, content: string): string {
return formatSSE({
jsonrpc: "2.0",
method: "message/stream",
params: {
task: { id: taskId, state: "working" },
chunk: { type: "text", content },
},
});
}
/**
* Create the final completion event with metadata.
*/
export function createCompletionEvent(taskId: string, metadata: Record<string, unknown>): string {
return formatSSE({
jsonrpc: "2.0",
method: "message/stream",
params: {
task: { id: taskId, state: "completed" },
metadata,
},
});
}
/**
* Create a heartbeat event to keep the connection alive.
*/
export function createHeartbeat(taskId: string): string {
return `: heartbeat ${new Date().toISOString()}\n\n`;
}
/**
* Create a failure event.
*/
export function createFailureEvent(taskId: string, error: string): string {
return formatSSE({
jsonrpc: "2.0",
method: "message/stream",
params: {
task: { id: taskId, state: "failed" },
metadata: { error },
},
});
}
/**
* SSE response headers for A2A streaming.
*/
export const SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
} as const;
/**
* Create a streaming SSE handler that wraps a fetch-based LLM call.
* Returns a ReadableStream suitable for a Response object.
*/
export function createA2AStream(
task: A2ATask,
executeSkill: (
task: A2ATask
) => Promise<{ artifacts: Array<{ content: string }>; metadata: Record<string, unknown> }>,
abortSignal?: AbortSignal,
lifecycle?: {
onStart?: () => void;
onEnd?: () => void;
}
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller) {
lifecycle?.onStart?.();
// Heartbeat interval
const heartbeatInterval = setInterval(() => {
try {
controller.enqueue(encoder.encode(createHeartbeat(task.id)));
} catch {
/* stream closed */
}
}, 15_000);
try {
// Check for cancellation
if (abortSignal?.aborted) {
controller.enqueue(encoder.encode(createFailureEvent(task.id, "Cancelled")));
controller.close();
return;
}
// Execute the skill
const result = await executeSkill(task);
// Emit content as chunks (simulated streaming for non-streaming skills)
for (const artifact of result.artifacts) {
if (abortSignal?.aborted) break;
controller.enqueue(encoder.encode(createChunkEvent(task.id, artifact.content)));
}
if (abortSignal?.aborted) {
controller.enqueue(encoder.encode(createFailureEvent(task.id, "Cancelled")));
return;
}
// Emit completion with metadata
controller.enqueue(encoder.encode(createCompletionEvent(task.id, result.metadata)));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
controller.enqueue(encoder.encode(createFailureEvent(task.id, msg)));
} finally {
clearInterval(heartbeatInterval);
lifecycle?.onEnd?.();
controller.close();
}
},
});
}
+64
View File
@@ -0,0 +1,64 @@
import type { A2ATask, TaskArtifact } from "./taskManager";
type TaskManagerLike = {
updateTask: (
taskId: string,
state: "completed" | "failed",
artifacts?: Array<{ type: string; content: string }>,
message?: string
) => unknown;
};
type StreamTaskResult = {
artifacts: TaskArtifact[];
metadata: Record<string, unknown>;
};
export type A2ASkillHandler = (task: A2ATask) => Promise<StreamTaskResult>;
export const A2A_SKILL_HANDLERS: Record<string, A2ASkillHandler> = {
"smart-routing": async (task) => {
const skillModule = await import("./skills/smartRouting");
return skillModule.executeSmartRouting(task);
},
"quota-management": async (task) => {
const skillModule = await import("./skills/quotaManagement");
return skillModule.executeQuotaManagement(task);
},
"provider-discovery": async (task) => {
const skillModule = await import("./skills/providerDiscovery");
return skillModule.executeProviderDiscovery(task);
},
"cost-analysis": async (task) => {
const skillModule = await import("./skills/costAnalysis");
return skillModule.executeCostAnalysis(task);
},
"health-report": async (task) => {
const skillModule = await import("./skills/healthReport");
return skillModule.executeHealthReport(task);
},
"list-capabilities": async (task) => {
const skillModule = await import("./skills/listCapabilities");
return skillModule.executeListCapabilities(task);
},
};
export async function executeA2ATaskWithState(
tm: TaskManagerLike,
task: A2ATask,
handler: (task: A2ATask) => Promise<StreamTaskResult>
) {
try {
const result = await handler(task);
tm.updateTask(task.id, "completed", result.artifacts);
return result;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
try {
tm.updateTask(task.id, "failed", [{ type: "error", content: msg }], msg);
} catch {
// Task may already be terminal (e.g., cancelled). Preserve original error.
}
throw err;
}
}
+240
View File
@@ -0,0 +1,240 @@
/**
* A2A Task Manager — Full lifecycle management for A2A tasks.
*
* State machine: submitted → working → completed | failed | cancelled
*
* Features:
* - UUID v4 task IDs
* - In-memory storage with optional SQLite persistence
* - Event logging for each state transition
* - TTL with configurable expiration (default 5 min)
* - Concurrent task limit
*/
import { randomUUID } from "crypto";
// ============ Types ============
export type TaskState = "submitted" | "working" | "completed" | "failed" | "cancelled";
export interface TaskInput {
skill: string;
messages: Array<{ role: string; content: string }>;
metadata?: Record<string, unknown>;
}
export interface TaskArtifact {
type: "text" | "json" | "error";
content: string;
}
export interface TaskEvent {
timestamp: string;
state: TaskState;
message?: string;
}
export interface A2ATask {
id: string;
skill: string;
state: TaskState;
input: TaskInput;
artifacts: TaskArtifact[];
events: TaskEvent[];
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
expiresAt: string;
}
export interface TaskListFilter {
state?: TaskState;
skill?: string;
limit?: number;
offset?: number;
}
export interface A2ATaskStats {
counts: Record<TaskState, number>;
total: number;
activeStreams: number;
lastTaskAt: string | null;
}
// ============ Valid Transitions ============
const VALID_TRANSITIONS: Record<TaskState, TaskState[]> = {
submitted: ["working", "failed", "cancelled"],
working: ["completed", "failed", "cancelled"],
completed: [],
failed: [],
cancelled: [],
};
// ============ Task Manager ============
export class A2ATaskManager {
private tasks = new Map<string, A2ATask>();
private readonly ttlMs: number;
private cleanupInterval: ReturnType<typeof setInterval>;
private activeStreams = 0;
constructor(ttlMinutes: number = 5) {
this.ttlMs = ttlMinutes * 60 * 1000;
this.cleanupInterval = setInterval(() => this.cleanupExpired(), 60_000);
if (
this.cleanupInterval &&
typeof this.cleanupInterval === "object" &&
"unref" in this.cleanupInterval
) {
(this.cleanupInterval as { unref?: () => void }).unref?.();
}
}
createTask(input: TaskInput): A2ATask {
const now = new Date();
const task: A2ATask = {
id: randomUUID(),
skill: input.skill,
state: "submitted",
input,
artifacts: [],
events: [{ timestamp: now.toISOString(), state: "submitted" }],
metadata: input.metadata || {},
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(),
};
this.tasks.set(task.id, task);
return task;
}
getTask(taskId: string): A2ATask | undefined {
const task = this.tasks.get(taskId);
if (task && new Date(task.expiresAt) < new Date()) {
if (task.state === "submitted" || task.state === "working") {
this.updateTask(taskId, "failed", undefined, "Task expired");
}
}
return this.tasks.get(taskId);
}
updateTask(
taskId: string,
state: TaskState,
artifacts?: TaskArtifact[],
message?: string
): A2ATask {
const task = this.tasks.get(taskId);
if (!task) throw new Error(`Task ${taskId} not found`);
const valid = VALID_TRANSITIONS[task.state];
if (!valid.includes(state)) {
throw new Error(`Invalid transition: ${task.state}${state}`);
}
const now = new Date().toISOString();
task.state = state;
task.updatedAt = now;
task.events.push({ timestamp: now, state, message });
if (artifacts) task.artifacts.push(...artifacts);
return task;
}
cancelTask(taskId: string): A2ATask {
return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client");
}
countTasks(filter?: Pick<TaskListFilter, "state" | "skill">): number {
let tasks = [...this.tasks.values()];
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
return tasks.length;
}
listTasks(filter?: TaskListFilter): A2ATask[] {
let tasks = [...this.tasks.values()];
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const offset = Math.max(0, filter?.offset || 0);
const limit =
typeof filter?.limit === "number" && Number.isFinite(filter.limit)
? Math.max(1, Math.floor(filter.limit))
: 50;
return tasks.slice(offset, offset + limit);
}
beginStream() {
this.activeStreams += 1;
}
endStream() {
this.activeStreams = Math.max(0, this.activeStreams - 1);
}
getStats(): A2ATaskStats {
const counts: Record<TaskState, number> = {
submitted: 0,
working: 0,
completed: 0,
failed: 0,
cancelled: 0,
};
let lastTaskAt: string | null = null;
for (const task of this.tasks.values()) {
counts[task.state] += 1;
const updatedAt = new Date(task.updatedAt).getTime();
if (!Number.isFinite(updatedAt)) continue;
if (!lastTaskAt || updatedAt > new Date(lastTaskAt).getTime()) {
lastTaskAt = task.updatedAt;
}
}
return {
counts,
total: this.tasks.size,
activeStreams: this.activeStreams,
lastTaskAt,
};
}
private cleanupExpired() {
const now = new Date();
for (const [id, task] of this.tasks) {
if (
new Date(task.expiresAt) < now &&
task.state !== "completed" &&
task.state !== "failed" &&
task.state !== "cancelled"
) {
task.state = "failed";
task.updatedAt = now.toISOString();
task.events.push({ timestamp: now.toISOString(), state: "failed", message: "TTL expired" });
}
// Remove terminal tasks older than 2x TTL
if (
["completed", "failed", "cancelled"].includes(task.state) &&
now.getTime() - new Date(task.updatedAt).getTime() > this.ttlMs * 2
) {
this.tasks.delete(id);
}
}
}
destroy() {
clearInterval(this.cleanupInterval);
}
}
// Singleton
const globalForA2A = globalThis as unknown as { _a2aTaskManager?: A2ATaskManager };
export function getTaskManager(): A2ATaskManager {
if (!globalForA2A._a2aTaskManager) {
globalForA2A._a2aTaskManager = new A2ATaskManager();
}
return globalForA2A._a2aTaskManager;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* CLI access-token scopes — the 3-level hierarchy used by remote mode.
*
* These tokens authorize the `omniroute` CLI (and dashboard) to run *management*
* commands against a (possibly remote) OmniRoute server. They are distinct from
* inference API keys (`api_keys`), which authorize `/v1/chat/completions` traffic.
*
* Hierarchy (admin ⊃ write ⊃ read):
* - read : list/inspect only (models list, providers status, logs, usage, cost)
* - write : read + configure/apply (setup-codex, keys add, config set, combo edit)
* - admin : write + sensitive management (tokens create/revoke, providers add,
* services install/start, policy, oauth)
*
* Loopback-only routes that spawn processes (`isLocalOnlyPath`) are NEVER reachable
* by a remote token regardless of scope — that enforcement happens before auth.
*/
export const ACCESS_SCOPES = ["read", "write", "admin"] as const;
export type AccessScope = (typeof ACCESS_SCOPES)[number];
/** Numeric rank for hierarchy comparisons. Higher = more privileged. */
const SCOPE_RANK: Record<AccessScope, number> = {
read: 1,
write: 2,
admin: 3,
};
/** Type guard: is `value` one of the three valid scopes? */
export function isAccessScope(value: unknown): value is AccessScope {
return typeof value === "string" && (ACCESS_SCOPES as readonly string[]).includes(value);
}
/**
* True when a token holding `have` is allowed to perform an action that requires
* `need`. Hierarchy is inclusive: an `admin` token satisfies `write` and `read`;
* a `write` token satisfies `read`. Unknown scopes never satisfy anything.
*/
export function scopeSatisfies(have: unknown, need: AccessScope): boolean {
if (!isAccessScope(have)) return false;
return SCOPE_RANK[have] >= SCOPE_RANK[need];
}
/**
* Normalize an arbitrary input into a valid scope, falling back to the safest
* default (`read`) when the value is missing or invalid. Used when reading a
* stored/declared scope that must never silently widen privileges.
*/
export function normalizeScope(value: unknown, fallback: AccessScope = "read"): AccessScope {
return isAccessScope(value) ? value : fallback;
}
+11
View File
@@ -0,0 +1,11 @@
/**
* ACP Module — Public API
*
* Re-exports the registry and manager for convenient imports.
*/
export { detectInstalledAgents, getAgentById, getAvailableAgents } from "./registry";
export type { CliAgentInfo } from "./registry";
export { AcpManager, acpManager } from "./manager";
export type { AcpSession } from "./manager";
+202
View File
@@ -0,0 +1,202 @@
/**
* ACP (Agent Client Protocol) — Process Spawner & Manager
*
* Spawns CLI agents as child processes and manages their lifecycle.
* Communication happens via stdin/stdout (JSON-RPC style) or piped HTTP.
*
* This module provides a "CLI-as-backend" transport: instead of intercepting
* HTTP API calls, OmniRoute spawns the CLI directly and feeds prompts through
* its native interface.
*/
import { spawn, ChildProcess } from "child_process";
import { EventEmitter } from "events";
export interface AcpSession {
/** Unique session ID */
id: string;
/** Agent ID (e.g., "codex", "claude") */
agentId: string;
/** Child process handle */
process: ChildProcess;
/** Whether the process is alive */
alive: boolean;
/** Accumulated stdout buffer */
stdoutBuffer: string;
/** Accumulated stderr buffer */
stderrBuffer: string;
/** Created timestamp */
createdAt: Date;
}
/**
* ACP Session Manager
*
* Manages the lifecycle of CLI agent processes.
* Each session represents one running CLI agent instance.
*/
export class AcpManager extends EventEmitter {
private sessions: Map<string, AcpSession> = new Map();
/**
* Spawn a new CLI agent process.
*/
spawn(
agentId: string,
binary: string,
args: string[] = [],
env: Record<string, string> = {}
): AcpSession {
const ALLOWED_AGENTS = ["claude", "codex", "gemini", "qwen"];
if (!ALLOWED_AGENTS.includes(agentId)) {
throw new Error(`Unknown agent: ${agentId}`);
}
const sessionId = `acp-${agentId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const child = spawn(binary, args, {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...env },
shell: false,
});
const session: AcpSession = {
id: sessionId,
agentId,
process: child,
alive: true,
stdoutBuffer: "",
stderrBuffer: "",
createdAt: new Date(),
};
child.stdout?.on("data", (chunk: Buffer) => {
session.stdoutBuffer += chunk.toString();
this.emit("stdout", { sessionId, data: chunk.toString() });
});
child.stderr?.on("data", (chunk: Buffer) => {
session.stderrBuffer += chunk.toString();
this.emit("stderr", { sessionId, data: chunk.toString() });
});
child.on("exit", (code, signal) => {
session.alive = false;
this.emit("exit", { sessionId, code, signal });
});
child.on("error", (err) => {
session.alive = false;
this.emit("error", { sessionId, error: err });
});
this.sessions.set(sessionId, session);
return session;
}
/**
* Send input to a running session's stdin.
*/
sendInput(sessionId: string, input: string): boolean {
const session = this.sessions.get(sessionId);
if (!session?.alive || !session.process.stdin?.writable) return false;
session.process.stdin.write(input);
return true;
}
/**
* Send a prompt to a CLI agent and collect the response.
* This is a higher-level method that handles the send/receive cycle.
*/
async sendPrompt(sessionId: string, prompt: string, timeoutMs: number = 120000): Promise<string> {
const session = this.sessions.get(sessionId);
if (!session?.alive) throw new Error(`Session ${sessionId} is not alive`);
// Clear buffer before sending
session.stdoutBuffer = "";
// Send prompt
this.sendInput(sessionId, prompt + "\n");
// Wait for response (collect until process goes idle or timeout)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`ACP timeout after ${timeoutMs}ms`));
}, timeoutMs);
let idleTimer: ReturnType<typeof setTimeout>;
const onData = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
// Reset idle timer on new data
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
clearTimeout(timer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
resolve(session.stdoutBuffer);
}, 2000); // 2s idle = response complete
};
const onExit = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
clearTimeout(timer);
clearTimeout(idleTimer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
resolve(session.stdoutBuffer);
};
this.on("stdout", onData);
this.on("exit", onExit);
});
}
/**
* Kill a session and clean up.
*/
kill(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
if (session.alive) {
session.process.kill("SIGTERM");
// Force kill after 5s
setTimeout(() => {
if (session.alive) {
session.process.kill("SIGKILL");
}
}, 5000);
}
this.sessions.delete(sessionId);
return true;
}
/**
* Get all active sessions.
*/
getActiveSessions(): AcpSession[] {
return Array.from(this.sessions.values()).filter((s) => s.alive);
}
/**
* Get a specific session.
*/
getSession(sessionId: string): AcpSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Kill all sessions.
*/
killAll(): void {
for (const [id] of this.sessions) {
this.kill(id);
}
}
}
// Singleton manager instance
export const acpManager = new AcpManager();
+384
View File
@@ -0,0 +1,384 @@
/**
* ACP (Agent Client Protocol) — CLI Agent Registry
*
* Discovers installed CLI tools on the system by checking standard paths
* and running version commands. Used to offer ACP transport as an alternative
* to the HTTP proxy method.
*
* Supports 14 built-in agents + user-defined custom agents from settings.
*
* Reference: https://github.com/iOfficeAI/AionUi (auto-detects CLI agents)
*/
import { execFileSync } from "child_process";
import path from "path";
export interface CliAgentInfo {
/** Agent identifier (e.g., "codex", "claude", "goose") */
id: string;
/** Display name */
name: string;
/** Binary name to spawn */
binary: string;
/** Version detection command */
versionCommand: string;
/** Detected version (null if not installed) */
version: string | null;
/** Whether the agent is installed and available */
installed: boolean;
/** Provider ID that this agent maps to in OmniRoute */
providerAlias: string;
/** Arguments to pass when spawning for ACP */
spawnArgs: string[];
/** Protocol used for communication */
protocol: "stdio" | "http";
/** Whether this is a user-defined custom agent */
isCustom?: boolean;
}
/** Shape stored in settings DB for custom agents */
export interface CustomAgentDef {
id: string;
name: string;
binary: string;
versionCommand: string;
providerAlias: string;
spawnArgs: string[];
protocol: "stdio" | "http";
}
/**
* Registry of known CLI agents that support ACP or similar protocols.
*/
const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
{
id: "codex",
name: "OpenAI Codex CLI",
binary: "codex",
versionCommand: "codex --version",
providerAlias: "codex",
spawnArgs: ["--quiet"],
protocol: "stdio",
},
{
id: "claude",
name: "Claude Code CLI",
binary: "claude",
versionCommand: "claude --version",
providerAlias: "claude",
spawnArgs: ["--print", "--output-format", "json"],
protocol: "stdio",
},
{
id: "goose",
name: "Goose CLI",
binary: "goose",
versionCommand: "goose --version",
providerAlias: "goose",
spawnArgs: [],
protocol: "stdio",
},
{
id: "openclaw",
name: "OpenClaw",
binary: "openclaw",
versionCommand: "openclaw --version",
providerAlias: "openclaw",
spawnArgs: [],
protocol: "stdio",
},
{
id: "aider",
name: "Aider",
binary: "aider",
versionCommand: "aider --version",
providerAlias: "aider",
spawnArgs: ["--no-auto-commits"],
protocol: "stdio",
},
{
id: "opencode",
name: "OpenCode",
binary: "opencode",
versionCommand: "opencode --version",
providerAlias: "opencode",
spawnArgs: [],
protocol: "stdio",
},
{
id: "cline",
name: "Cline",
binary: "cline",
versionCommand: "cline --version",
providerAlias: "cline",
spawnArgs: [],
protocol: "stdio",
},
{
id: "qwen-code",
name: "Qwen Code",
binary: "qwen",
versionCommand: "qwen --version",
providerAlias: "qwen",
spawnArgs: [],
protocol: "stdio",
},
{
id: "forge",
name: "ForgeCode",
binary: "forge",
versionCommand: "forge --version",
providerAlias: "forge",
spawnArgs: [],
protocol: "stdio",
},
{
id: "amazon-q",
name: "Amazon Q Developer",
binary: "q",
versionCommand: "q --version",
providerAlias: "amazon-q",
spawnArgs: [],
protocol: "stdio",
},
{
id: "interpreter",
name: "Open Interpreter",
binary: "interpreter",
versionCommand: "interpreter --version",
providerAlias: "interpreter",
spawnArgs: [],
protocol: "stdio",
},
{
id: "cursor-cli",
name: "Cursor CLI",
binary: "cursor",
versionCommand: "cursor --version",
providerAlias: "cursor",
spawnArgs: [],
protocol: "stdio",
},
{
id: "warp",
name: "Warp AI",
binary: "warp",
versionCommand: "warp --version",
providerAlias: "warp",
spawnArgs: [],
protocol: "stdio",
},
];
// ---------------------------------------------------------------------------
// Detection cache (60 seconds)
// ---------------------------------------------------------------------------
let _cachedAgents: CliAgentInfo[] | null = null;
let _cacheTimestamp = 0;
const CACHE_TTL_MS = 60_000;
/** Custom agents loaded from settings */
let _customAgentDefs: CustomAgentDef[] = [];
const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
/**
* Set custom agent definitions from settings.
*/
export function setCustomAgents(agents: CustomAgentDef[]): void {
_customAgentDefs = agents || [];
_cachedAgents = null; // invalidate cache
}
/**
* Get current custom agent definitions.
*/
export function getCustomAgentDefs(): CustomAgentDef[] {
return _customAgentDefs;
}
function tokenizeVersionCommand(command: string): string[] | null {
if (!command || DISALLOWED_VERSION_COMMAND_CHARS.test(command)) {
return null;
}
const tokens: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (let index = 0; index < command.length; index += 1) {
const char = command[index];
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
if (char === "\\") {
const next = command[index + 1];
if (next) {
current += next;
index += 1;
continue;
}
}
current += char;
}
if (quote) {
return null;
}
if (current) {
tokens.push(current);
}
return tokens.length > 0 ? tokens : null;
}
function normalizeCommandToken(command: string): string {
return path.normalize(command).replace(/\\/g, "/").toLowerCase();
}
export function resolveVersionProbe(
binary: string,
versionCommand: string,
requireBinaryMatch = false
): { command: string; args: string[] } | null {
const tokens = tokenizeVersionCommand(versionCommand);
if (!tokens) {
return null;
}
const [command, ...args] = tokens;
if (!command) {
return null;
}
if (requireBinaryMatch) {
const normalizedCommand = normalizeCommandToken(command);
const allowed = new Set([
normalizeCommandToken(binary),
normalizeCommandToken(path.basename(binary)),
]);
if (!allowed.has(normalizedCommand)) {
return null;
}
}
return { command, args };
}
export function shouldUseShellForVersionProbe(
command: string,
platform = process.platform
): boolean {
if (platform !== "win32") return false;
const normalized = command.trim().toLowerCase();
if (!normalized) return false;
return (
normalized.endsWith(".cmd") || normalized.endsWith(".bat") || path.extname(normalized) === ""
);
}
/**
* Detect a single agent by running its version command.
*/
function detectAgent(
def: Omit<CliAgentInfo, "version" | "installed">,
isCustom = false
): CliAgentInfo {
let version: string | null = null;
let installed = false;
try {
const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);
if (!probe) {
return { ...def, version, installed, isCustom };
}
const output = execFileSync(probe.command, probe.args, {
timeout: 5000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
}).trim();
// Extract version number from output
const versionMatch = output.match(/(\d+\.\d+\.\d+(?:-\w+)?)/);
version = versionMatch ? versionMatch[1] : output.split("\n")[0];
installed = true;
} catch {
// Not installed or not runnable
}
return { ...def, version, installed, isCustom };
}
/**
* Detect installed CLI agents on the system.
* Results are cached for 60 seconds.
*/
export function detectInstalledAgents(): CliAgentInfo[] {
const now = Date.now();
if (_cachedAgents && now - _cacheTimestamp < CACHE_TTL_MS) {
return _cachedAgents;
}
// Merge built-in + custom definitions
const allDefs = [
...AGENT_DEFINITIONS.map((d) => ({ ...d, _custom: false })),
..._customAgentDefs.map((d) => ({ ...d, _custom: true })),
];
_cachedAgents = allDefs.map((def) => {
const { _custom, ...rest } = def;
return detectAgent(rest, _custom);
});
_cacheTimestamp = now;
return _cachedAgents;
}
/**
* Force refresh detection cache.
*/
export function refreshAgentCache(): CliAgentInfo[] {
_cachedAgents = null;
return detectInstalledAgents();
}
/**
* Get a specific agent by ID.
*/
export function getAgentById(id: string): CliAgentInfo | undefined {
const agents = detectInstalledAgents();
return agents.find((a) => a.id === id);
}
/**
* Get agents that are installed and available for ACP.
*/
export function getAvailableAgents(): CliAgentInfo[] {
return detectInstalledAgents().filter((a) => a.installed);
}
+256
View File
@@ -0,0 +1,256 @@
/**
* catalog.ts — single source of truth for the 43-entry Agent Skills catalog.
*
* Consumers: REST routes (/api/agent-skills/*), MCP tools, A2A skill, Generator.
* Do NOT import this from UI components directly — use the REST API instead.
*/
import fs from "node:fs";
import path from "node:path";
import type { AgentSkill, SkillCoverage, SkillMarkdown } from "./types";
import {
CURATED_SKILLS,
getAgentSkillRawUrl,
getAgentSkillBlobUrl,
} from "@/shared/constants/agentSkills";
// ── Canonical ID lists (D28) ────────────────────────────────────────────────
/** 22 canonical API skill IDs, in spec order. */
export const API_SKILL_IDS: readonly string[] = [
"omni-auth",
"omni-providers",
"omni-models",
"omni-combos-routing",
"omni-api-keys",
"omni-usage-logs",
"omni-budget",
"omni-settings",
"omni-proxies",
"omni-cache",
"omni-compression",
"omni-context-rtk",
"omni-resilience",
"omni-cli-tools",
"omni-tunnels",
"omni-sync-cloud",
"omni-db-backups",
"omni-webhooks",
"omni-mcp",
"omni-agents-a2a",
"omni-version-manager",
"omni-inference",
"omni-github-skills",
] as const;
/** Config skill IDs. */
export const CONFIG_SKILL_IDS: readonly string[] = ["config-codex-cli"] as const;
/** 20 canonical CLI skill IDs, in spec order. */
export const CLI_SKILL_IDS: readonly string[] = [
"cli-serve",
"cli-health",
"cli-providers",
"cli-keys",
"cli-models",
"cli-chat",
"cli-routing",
"cli-resilience",
"cli-compression",
"cli-contexts",
"cli-cost-usage",
"cli-mcp",
"cli-a2a",
"cli-tunnel",
"cli-backup-sync",
"cli-policy-audit",
"cli-batches",
"cli-eval",
"cli-plugins-skills",
"cli-setup",
] as const;
// ── Module-scope cache ──────────────────────────────────────────────────────
let _cache: AgentSkill[] | null = null;
// ── Helpers ─────────────────────────────────────────────────────────────────
function buildFullSkill(curated: (typeof CURATED_SKILLS)[number]): AgentSkill {
return {
...curated,
endpoints: curated.category === "api" ? [] : undefined,
cliCommands: curated.category === "cli" ? [] : undefined,
rawUrl: getAgentSkillRawUrl(curated.id),
githubUrl: getAgentSkillBlobUrl(curated.id),
};
}
function deriveCatalog(): AgentSkill[] {
return CURATED_SKILLS.map(buildFullSkill);
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Returns the full catalog (43 entries). Cached in module scope after first call.
* Safe to call multiple times — re-derives only after `refreshCatalog()`.
*/
export function getCatalog(): AgentSkill[] {
if (!_cache) {
_cache = deriveCatalog();
}
return _cache;
}
/** Returns single skill metadata or null. */
export function getSkillById(id: string): AgentSkill | null {
return getCatalog().find((s) => s.id === id) ?? null;
}
/** Filters catalog by category and/or area. */
export function filterCatalog(opts: { category?: "api" | "cli"; area?: string }): AgentSkill[] {
let skills = getCatalog();
if (opts.category) {
skills = skills.filter((s) => s.category === opts.category);
}
if (opts.area) {
skills = skills.filter((s) => s.area === opts.area);
}
return skills;
}
/**
* Computes coverage stats: filesystem has SKILL.md vs catalog declares 42.
* Reads `skills/` relative to the project root (CWD).
*/
export function computeCoverage(): SkillCoverage {
const catalog = getCatalog();
const skillsDir = path.resolve(process.cwd(), "skills");
let presentIds: Set<string>;
try {
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
presentIds = new Set(
entries
.filter((e) => e.isDirectory())
.filter((e) => fs.existsSync(path.join(skillsDir, e.name, "SKILL.md")))
.map((e) => e.name)
);
} catch {
// Directory doesn't exist yet — zero coverage
presentIds = new Set();
}
const apiHave = catalog.filter((s) => s.category === "api" && presentIds.has(s.id)).length;
const cliHave = catalog.filter((s) => s.category === "cli" && presentIds.has(s.id)).length;
const configTotal = CONFIG_SKILL_IDS.length;
const configHave = catalog.filter((s) => s.category === "config" && presentIds.has(s.id)).length;
return {
api: { have: apiHave, total: 23 },
cli: { have: cliHave, total: 20 },
config: { have: configHave, total: configTotal },
totalSkills: apiHave + cliHave + configHave,
generatedAt: new Date().toISOString(),
};
}
/**
* Forces re-derivation of the catalog on next `getCatalog()` call.
* Used by tests and by the generator after writing new SKILL.md files.
*/
export function refreshCatalog(): void {
_cache = null;
}
/**
* Fetches the SKILL.md content for a given skill ID.
*
* Resolution order:
* 1. Local filesystem `skills/{id}/SKILL.md` (fast, used during dev + after generation)
* 2. GitHub raw URL with 1-hour cache (production fallback when file not yet generated)
*
* Returns a `SkillMarkdown` shape. Throws if both sources fail.
* Used by: F4 `/api/agent-skills/[id]/raw` route.
*/
export async function fetchSkillMarkdown(id: string): Promise<SkillMarkdown> {
const localPath = path.resolve(process.cwd(), "skills", id, "SKILL.md");
// 1. Try filesystem first
try {
const raw = fs.readFileSync(localPath, "utf-8");
const parsed = parseMarkdownFrontmatter(raw);
return {
id,
frontmatter: parsed.frontmatter,
body: parsed.body,
source: "filesystem",
fetchedAt: new Date().toISOString(),
};
} catch {
// File not present locally — fall through to GitHub
}
// 2. Fetch from GitHub raw (with Next.js revalidate cache if available)
const skill = getSkillById(id);
if (!skill) {
throw new Error(`Skill not found in catalog: ${id}`);
}
const response = await fetch(skill.rawUrl, {
next: { revalidate: 3600 },
} as unknown as RequestInit);
if (!response.ok) {
throw new Error(`GitHub raw fetch failed: HTTP ${response.status} for ${skill.rawUrl}`);
}
const raw = await response.text();
const parsed = parseMarkdownFrontmatter(raw);
return {
id,
frontmatter: parsed.frontmatter,
body: parsed.body,
source: "github",
fetchedAt: new Date().toISOString(),
};
}
// ── Internal helpers ─────────────────────────────────────────────────────────
/**
* Parses YAML frontmatter from a markdown string.
* Expects: `---\nkey: value\n---\n<body>` format.
* Returns default values if frontmatter is absent or malformed.
*/
function parseMarkdownFrontmatter(content: string): {
frontmatter: { name: string; description: string };
body: string;
} {
const FM_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
const match = FM_REGEX.exec(content);
if (!match) {
return {
frontmatter: { name: "", description: "" },
body: content,
};
}
const yamlBlock = match[1];
const body = match[2] ?? "";
// Simple key: value extraction (avoids importing js-yaml here to stay lightweight)
const nameMatch = /^name:\s*(.+)$/m.exec(yamlBlock);
const descMatch = /^description:\s*(.+)$/m.exec(yamlBlock);
return {
frontmatter: {
name: nameMatch ? nameMatch[1].trim().replace(/^["']|["']$/g, "") : "",
description: descMatch ? descMatch[1].trim().replace(/^["']|["']$/g, "") : "",
},
body,
};
}
+242
View File
@@ -0,0 +1,242 @@
/**
* cliRegistryParser.ts — regex-based parser for bin/cli/commands/*.mjs files.
*
* Extracts command families and their subcommands WITHOUT importing the modules
* (Commander.js has side-effects when required as a program instance, D15).
*
* Parse strategy:
* - Read all *.mjs files under bin/cli/commands/
* - Detect top-level command name from `.command("<name>")` patterns
* - Detect subcommands from chained `.command("<sub>")` patterns
* - Map file basename → SkillArea family via FAMILY_MAP
*/
import fs from "node:fs";
import path from "node:path";
import type { SkillArea } from "./types";
// ── Types ────────────────────────────────────────────────────────────────────
export interface CliCommand {
/** Canonical command string, e.g. "providers list" */
name: string;
/** Description extracted from .description("...") */
description: string;
/** Flags extracted from .option("--flag", "...") */
flags: string[];
/** Whether this is a top-level command (depth 0) or subcommand (depth > 0) */
isSubcommand: boolean;
}
export interface ParsedCliRegistry {
/** All commands keyed by their full name, e.g. "providers list" */
commands: Map<string, CliCommand>;
/** Commands grouped by SkillArea family */
families: Map<SkillArea, CliCommand[]>;
}
// ── Mapping: file basename → CLI SkillArea ───────────────────────────────────
/**
* Maps a commands/*.mjs basename to its CLI SkillArea.
* Files that don't map to a known family are ignored.
*/
const FILE_FAMILY_MAP: Record<string, SkillArea> = {
"serve": "cli-serve",
"dashboard": "cli-serve",
"stop": "cli-serve",
"restart": "cli-serve",
"health": "cli-health",
"status": "cli-health",
"doctor": "cli-health",
"providers": "cli-providers",
"provider-cmd": "cli-providers",
"test-provider": "cli-providers",
"keys": "cli-keys",
"oauth": "cli-keys",
"models": "cli-models",
"chat": "cli-chat",
"stream": "cli-chat",
"repl": "cli-chat",
"combo": "cli-routing",
"routing": "cli-routing",
"resilience": "cli-resilience",
"quota": "cli-resilience",
"compression": "cli-compression",
"context-eng": "cli-contexts",
"contexts": "cli-contexts",
"sessions": "cli-contexts",
"cost": "cli-cost-usage",
"usage": "cli-cost-usage",
"pricing": "cli-cost-usage",
"mcp": "cli-mcp",
"a2a": "cli-a2a",
"tunnel": "cli-tunnel",
"backup": "cli-backup-sync",
"sync": "cli-backup-sync",
"cloud": "cli-backup-sync",
"audit": "cli-policy-audit",
"policy": "cli-policy-audit",
"logs": "cli-policy-audit",
"telemetry": "cli-policy-audit",
"batches": "cli-batches",
"files": "cli-batches",
"eval": "cli-eval",
"simulate": "cli-eval",
"skills": "cli-plugins-skills",
"plugin": "cli-plugins-skills",
"memory": "cli-plugins-skills",
"setup": "cli-setup",
"config": "cli-setup",
"env": "cli-setup",
"update": "cli-setup",
"autostart": "cli-setup",
};
// ── Regex patterns ───────────────────────────────────────────────────────────
// Matches: .command("name") or .command('name') — capture group 1 = name
const COMMAND_RE = /\.command\(\s*["']([^"']+)["']/g;
// Matches: .description("text") or .description('text') — capture group 1 = text
const DESCRIPTION_RE = /\.description\(\s*["']([^"']+)["']/g;
// Matches: .option("--flag ...", "desc") — capture group 1 = flag string
const OPTION_RE = /\.option\(\s*["']([^"']+)["']/g;
// ── Parser helpers ───────────────────────────────────────────────────────────
interface RawCommand {
name: string;
description: string;
flags: string[];
}
/**
* Extracts all commands (and their immediately following description + options)
* from a single .mjs file content.
*
* Limitation: uses regex, not a full AST — deeply nested or dynamically
* constructed commands may be missed. This is acceptable for the catalog use-case
* where we want a list of known subcommand names, not runtime-validated metadata.
*/
function extractCommandsFromContent(content: string, topLevelName: string): RawCommand[] {
const commands: RawCommand[] = [];
// Find all .command() call positions
COMMAND_RE.lastIndex = 0;
let match: RegExpExecArray | null;
const commandMatches: Array<{ name: string; index: number }> = [];
while ((match = COMMAND_RE.exec(content)) !== null) {
commandMatches.push({ name: match[1], index: match.index });
}
for (let i = 0; i < commandMatches.length; i++) {
const { name: rawName, index: cmdIndex } = commandMatches[i];
const nextIndex = commandMatches[i + 1]?.index ?? content.length;
// Slice between this command call and the next to scope description/options
const slice = content.slice(cmdIndex, nextIndex);
// Extract description (first match in slice)
DESCRIPTION_RE.lastIndex = 0;
const descMatch = DESCRIPTION_RE.exec(slice);
const description = descMatch ? descMatch[1] : "";
// Extract flags in slice
const flags: string[] = [];
OPTION_RE.lastIndex = 0;
let optMatch: RegExpExecArray | null;
while ((optMatch = OPTION_RE.exec(slice)) !== null) {
flags.push(optMatch[1]);
}
// Compose full command name:
// - If rawName equals the top-level name (or is the isDefault pattern), use as-is
// - Otherwise, qualify as "topLevel subname"
const isTopLevel =
rawName === topLevelName ||
rawName.startsWith(topLevelName + " ") ||
// Some files declare standalone root commands (e.g. serve, health)
!rawName.includes(" ");
const fullName = isTopLevel && i === 0 ? rawName : `${topLevelName} ${rawName}`;
commands.push({ name: fullName.trim(), description, flags });
}
return commands;
}
// ── Main export ──────────────────────────────────────────────────────────────
/**
* Reads all `bin/cli/commands/*.mjs` files and extracts CLI command metadata.
*
* Returns:
* - `commands`: flat map of all commands by full name
* - `families`: commands grouped by SkillArea
*/
export function parseCliRegistry(): ParsedCliRegistry {
const commandsDir = path.resolve(process.cwd(), "bin", "cli", "commands");
let files: string[];
try {
files = fs.readdirSync(commandsDir).filter((f) => f.endsWith(".mjs"));
} catch (err) {
throw new Error(
`cliRegistryParser: could not read ${commandsDir}. ` +
`Run from project root. Underlying error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const commands = new Map<string, CliCommand>();
const families = new Map<SkillArea, CliCommand[]>();
for (const file of files) {
const basename = path.basename(file, ".mjs");
const family = FILE_FAMILY_MAP[basename];
if (!family) continue; // skip unrecognised files (e.g. runtime.mjs, repl.mjs)
const filePath = path.join(commandsDir, file);
let content: string;
try {
content = fs.readFileSync(filePath, "utf-8");
} catch {
continue; // skip unreadable files
}
const rawCmds = extractCommandsFromContent(content, basename);
if (rawCmds.length === 0) continue;
for (let i = 0; i < rawCmds.length; i++) {
const rc = rawCmds[i];
const cliCmd: CliCommand = {
name: rc.name,
description: rc.description,
flags: rc.flags,
isSubcommand: i > 0,
};
commands.set(rc.name, cliCmd);
if (!families.has(family)) {
families.set(family, []);
}
families.get(family)!.push(cliCmd);
}
}
return { commands, families };
}
/**
* Returns command name strings for a given CLI SkillArea family,
* suitable for `AgentSkill.cliCommands`.
*/
export function getCommandsForFamily(family: SkillArea): string[] {
const { families } = parseCliRegistry();
const cmds = families.get(family) ?? [];
return cmds.map((c) => c.name);
}
+367
View File
@@ -0,0 +1,367 @@
/**
* generator.ts — idempotent SKILL.md generator for all 42 agent skills.
*
* Usage (library):
* import { generateAgentSkills, buildSkillMarkdown } from "@/lib/agentSkills/generator";
*
* Usage (CLI):
* node scripts/skills/generate-agent-skills.mjs [--apply] [--prune] [--only=id1,id2] [--json]
*
* Safety contract:
* - Default: dryRun=true, prune=false (no filesystem writes).
* - dryRun=false is required for any actual writes.
* - prune=true AND dryRun=false is required to delete orphan directories.
* - F9 is responsible for running apply; F3 only implements + tests the generator.
*/
import fs from "node:fs";
import path from "node:path";
import { getCatalog, refreshCatalog } from "./catalog";
import { parseOpenapi } from "./openapiParser";
import { parseCliRegistry } from "./cliRegistryParser";
import type { AgentSkill, GeneratorOptions, GeneratorReport } from "./types";
import type { ParsedOpenapi } from "./openapiParser";
import type { ParsedCliRegistry } from "./cliRegistryParser";
// ── Constants ─────────────────────────────────────────────────────────────────
const GENERATED_COMMENT =
"<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->";
const CUSTOM_START_MARKER = "<!-- skill:custom-start -->";
const CUSTOM_END_MARKER = "<!-- skill:custom-end -->";
// ── Types ─────────────────────────────────────────────────────────────────────
interface BuildSources {
openapi: ParsedOpenapi;
cliRegistry: ParsedCliRegistry;
}
// ── Frontmatter helpers ───────────────────────────────────────────────────────
/**
* Serializes frontmatter to YAML front-matter block.
* Handles descriptions with colons or special characters safely.
*/
function serializeFrontmatter(fm: { name: string; description: string }): string {
// Use block scalar for description if it contains colons, quotes, or newlines
const needsQuote = (v: string): boolean => /[:\n"']/.test(v) || v.startsWith(" ");
// Escape order matters for double-quoted YAML scalars: backslash FIRST (so the
// escapes we add below are not themselves re-escaped), then the double-quote,
// then collapse real newlines into the \n escape sequence.
const escDq = (v: string): string => v.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const nameStr = needsQuote(fm.name) ? `"${escDq(fm.name)}"` : fm.name;
const descStr = needsQuote(fm.description)
? `"${escDq(fm.description).replace(/\n/g, "\\n")}"`
: fm.description;
return `---\nname: ${nameStr}\ndescription: ${descStr}\n---\n`;
}
/**
* Extracts the custom block content from an existing SKILL.md string.
* Returns null if no custom block is found.
*/
function extractCustomBlock(content: string): string | null {
const startIdx = content.indexOf(CUSTOM_START_MARKER);
const endIdx = content.indexOf(CUSTOM_END_MARKER);
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return null;
return content.slice(startIdx, endIdx + CUSTOM_END_MARKER.length);
}
// ── Body builders ─────────────────────────────────────────────────────────────
function buildApiBody(skill: AgentSkill, sources: BuildSources): string {
const areaMap = sources.openapi.areas;
const ops = areaMap.get(skill.area as Parameters<typeof areaMap.get>[0]) ?? [];
const lines: string[] = [];
lines.push("## Overview\n");
lines.push(skill.description);
lines.push("");
lines.push("## Authentication\n");
lines.push(
"All requests require a valid Bearer token or session cookie. " +
"Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development."
);
lines.push("");
lines.push("## Endpoints\n");
if (ops.length === 0) {
lines.push("_No endpoints mapped for this area yet._");
} else {
for (const op of ops) {
lines.push(`### ${op.method} ${op.path}\n`);
if (op.summary) {
lines.push(`${op.summary}`);
lines.push("");
}
if (op.description) {
lines.push(op.description);
lines.push("");
}
// Minimal curl example
const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `;
lines.push("```bash");
lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`);
lines.push(' -H "Authorization: Bearer $OMNIROUTE_TOKEN"');
if (["POST", "PUT", "PATCH"].includes(op.method)) {
lines.push(' -H "Content-Type: application/json" \\');
lines.push(" -d '{}'");
}
lines.push("```");
lines.push("");
}
}
lines.push("## Payloads\n");
lines.push(
"See the full OpenAPI specification at `GET /api/openapi/spec` or " +
"`docs/openapi.yaml` for detailed request/response schemas."
);
lines.push("");
return lines.join("\n");
}
function buildCliBody(skill: AgentSkill, sources: BuildSources): string {
const familyMap = sources.cliRegistry.families;
const cmds = familyMap.get(skill.area as Parameters<typeof familyMap.get>[0]) ?? [];
const lines: string[] = [];
lines.push("## Overview\n");
lines.push(skill.description);
lines.push("");
lines.push("## Quick install\n");
lines.push("```bash");
lines.push("npm install -g omniroute # or: npx omniroute");
lines.push("omniroute --version");
lines.push("```");
lines.push("");
lines.push("## Subcommands\n");
if (cmds.length === 0) {
lines.push("_No CLI subcommands mapped for this family yet._");
lines.push("");
} else {
for (const cmd of cmds) {
lines.push(`### \`${cmd.name}\`\n`);
if (cmd.description) {
lines.push(cmd.description);
lines.push("");
}
if (cmd.flags.length > 0) {
lines.push("**Flags:**\n");
for (const flag of cmd.flags) {
lines.push(`- \`${flag}\``);
}
lines.push("");
}
lines.push("**Example:**\n");
lines.push("```bash");
lines.push(`omniroute ${cmd.name}`);
lines.push("```");
lines.push("");
}
}
return lines.join("\n");
}
// ── Core builder ─────────────────────────────────────────────────────────────
/**
* Generates the full SKILL.md string (frontmatter + comment + body) for a given skill.
*
* If `existingContent` is provided, any `<!-- skill:custom-start --> ... <!-- skill:custom-end -->`
* block found in it is re-injected after the generated body (marker preservation / D24).
*/
export function buildSkillMarkdown(
skillId: string,
sources: BuildSources,
existingContent?: string
): { frontmatter: { name: string; description: string }; body: string } {
const skill = getCatalog().find((s) => s.id === skillId);
if (!skill) {
throw new Error(`buildSkillMarkdown: skill "${skillId}" not found in catalog`);
}
const fm = {
name: skill.id,
description: skill.description.slice(0, 2000),
};
const bodyLines =
skill.category === "api" ? buildApiBody(skill, sources) : buildCliBody(skill, sources);
// Re-inject custom block if present in existing content
let customBlock = "";
if (existingContent) {
const extracted = extractCustomBlock(existingContent);
if (extracted) {
customBlock = `\n${extracted}\n`;
}
}
const body = GENERATED_COMMENT + "\n\n" + bodyLines + customBlock;
return { frontmatter: fm, body };
}
/**
* Assembles the full file content string from frontmatter + body.
*/
function assembleFileContent(fm: { name: string; description: string }, body: string): string {
return serializeFrontmatter(fm) + body;
}
// ── Main generator ─────────────────────────────────────────────────────────────
/**
* Runs the full generator. Idempotent.
*
* - dryRun=true (default): returns report but writes nothing to disk.
* - dryRun=false: writes/updates SKILL.md files as needed.
* - prune=true: detects orphan directories in `skills/` not in the catalog.
* - dryRun=true + prune=true: lists orphans, no deletions.
* - dryRun=false + prune=true: deletes orphan directories.
*/
export async function generateAgentSkills(opts: GeneratorOptions): Promise<GeneratorReport> {
const { dryRun = true, prune = false, outputDir = "skills", onlyIds } = opts;
// Anchor the base path with a literal so Turbopack's static analyzer can resolve
// it without falling back to tracing the entire project root. (#6329)
// Honor an absolute outputDir (e.g. a tmp dir in tests) — path.join(cwd, "/abs")
// would mangle it into cwd/abs, so guard with isAbsolute while keeping the
// Turbopack-friendly join form for the common relative case ("skills"). (#6366 regression)
const outputBase = path.isAbsolute(outputDir) ? outputDir : path.join(process.cwd(), outputDir);
const catalog = getCatalog();
const catalogIds = new Set(catalog.map((s) => s.id));
// Filter to onlyIds if provided
const skillsToProcess = onlyIds ? catalog.filter((s) => onlyIds.includes(s.id)) : catalog;
// Lazily parse sources (only once per generator run)
let _openapi: ParsedOpenapi | null = null;
let _cliRegistry: ParsedCliRegistry | null = null;
function getSources(): BuildSources {
if (!_openapi) {
try {
_openapi = parseOpenapi();
} catch {
_openapi = { paths: new Map(), areas: new Map() };
}
}
if (!_cliRegistry) {
try {
_cliRegistry = parseCliRegistry();
} catch {
_cliRegistry = { commands: new Map(), families: new Map() };
}
}
return { openapi: _openapi, cliRegistry: _cliRegistry };
}
const report: GeneratorReport = {
generated: [],
unchanged: [],
pruned: [],
orphansDetected: [],
errors: [],
};
// ── Detect orphans ──────────────────────────────────────────────────────────
if (prune) {
let dirs: string[] = [];
try {
dirs = fs
.readdirSync(outputBase, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
// outputDir doesn't exist yet — no orphans
}
for (const dir of dirs) {
if (!catalogIds.has(dir)) {
report.orphansDetected.push(dir);
if (!dryRun) {
try {
fs.rmSync(path.join(outputBase, dir), { recursive: true, force: true });
report.pruned.push(dir);
} catch (err) {
report.errors.push({
id: dir,
error: `Failed to prune: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
}
}
}
// ── Generate / compare each skill ──────────────────────────────────────────
const sources = getSources();
for (const skill of skillsToProcess) {
try {
const skillDir = path.join(outputBase, skill.id);
const skillFile = path.join(skillDir, "SKILL.md");
// Read existing content for marker preservation
let existingContent: string | undefined;
try {
existingContent = fs.readFileSync(skillFile, "utf-8");
} catch {
existingContent = undefined;
}
const { frontmatter, body } = buildSkillMarkdown(skill.id, sources, existingContent);
const newContent = assembleFileContent(frontmatter, body);
// Compare with existing to detect actual changes
if (existingContent !== undefined && existingContent === newContent) {
report.unchanged.push(skill.id);
continue;
}
if (dryRun) {
// In dry-run: classify as generated (would write) but don't touch disk
report.generated.push(skill.id);
} else {
// Apply: ensure directory + write file
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(skillFile, newContent, "utf-8");
report.generated.push(skill.id);
}
} catch (err) {
report.errors.push({
id: skill.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
// After writing, refresh catalog cache so subsequent calls reflect new files
if (!dryRun && report.generated.length > 0) {
refreshCatalog();
}
return report;
}
// Exported for unit testing only — keep the internal helpers reachable without
// widening the public surface.
export const __testing = { serializeFrontmatter };
+201
View File
@@ -0,0 +1,201 @@
/**
* openapiParser.ts — parses docs/openapi.yaml to extract endpoint info
* grouped by SkillArea. Used by the catalog and the generator.
*
* Reads the OpenAPI YAML synchronously at runtime (same pattern as
* src/app/api/openapi/spec/route.ts). Does NOT fetch via HTTP to remain
* usable as a standalone script/CI tool (D15).
*/
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
import type { SkillArea } from "./types";
// ── Types ────────────────────────────────────────────────────────────────────
export interface OpenapiPath {
/** HTTP method (uppercase): "GET", "POST", etc. */
method: string;
/** OpenAPI path template, e.g. "/api/providers/{id}" */
path: string;
/** Summary from the operation object */
summary: string;
/** Description from the operation object (may be absent) */
description?: string;
/** OpenAPI tags */
tags: string[];
}
export interface ParsedOpenapi {
/** All endpoints keyed by "<METHOD> <path>" */
paths: Map<string, OpenapiPath>;
/** Endpoints grouped by SkillArea (only API-mapped areas) */
areas: Map<SkillArea, OpenapiPath[]>;
}
// ── Mapping: path prefix → SkillArea ────────────────────────────────────────
/**
* Maps an API path prefix to the corresponding SkillArea.
* Order matters: more specific prefixes must come before generic ones.
*/
const PATH_AREA_MAP: Array<[string, SkillArea]> = [
// Auth
["/api/auth", "auth"],
["/api/session", "auth"],
// Providers
["/api/providers", "providers"],
["/api/provider-nodes", "providers"],
["/api/provider-models", "providers"],
// Models
["/api/v1/models", "models"],
["/api/models", "models"],
// Combos / routing
["/api/combos", "combos-routing"],
["/api/fallback", "combos-routing"],
// API Keys
["/api/keys", "api-keys"],
// Usage logs
["/api/usage", "usage-logs"],
// Budget / rate limit
["/api/rate-limit", "budget"],
["/api/budget", "budget"],
// Settings
["/api/settings", "settings"],
["/api/tags", "settings"],
// Proxies
["/api/settings/proxy", "proxies"],
// Cache
["/api/cache", "cache"],
// Compression / RTK
["/api/settings/compression", "compression"],
["/api/compression", "compression"],
["/api/context/rtk", "context-rtk"],
// Resilience
["/api/monitoring", "resilience"],
["/api/provider-metrics", "resilience"],
["/api/circuit-breakers", "resilience"],
// CLI tools
["/api/cli-tools", "cli-tools"],
// Tunnels
["/api/tunnel", "tunnels"],
// Sync / cloud
["/api/cloud", "sync-cloud"],
["/api/sync", "sync-cloud"],
// DB backups
["/api/system", "db-backups"],
["/api/backup", "db-backups"],
// Webhooks
["/api/webhooks", "webhooks"],
// MCP
["/api/mcp", "mcp"],
// A2A
["/a2a", "agents-a2a"],
// Version manager
["/api/services", "version-manager"],
["/api/version", "version-manager"],
// Inference (catch-all for /api/v1/* proxy endpoints)
["/api/v1", "inference"],
];
// ── HTTP methods recognised as operations ────────────────────────────────────
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const;
// ── Parser ───────────────────────────────────────────────────────────────────
function resolveArea(urlPath: string): SkillArea | null {
for (const [prefix, area] of PATH_AREA_MAP) {
if (
urlPath === prefix ||
urlPath.startsWith(prefix + "/") ||
urlPath.startsWith(prefix + "{")
) {
return area;
}
}
return null;
}
function extractOperations(pathsObj: Record<string, any>): OpenapiPath[] {
const ops: OpenapiPath[] = [];
for (const [urlPath, pathItem] of Object.entries(pathsObj)) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!operation || typeof operation !== "object") continue;
ops.push({
method: method.toUpperCase(),
path: urlPath,
summary: String(operation.summary ?? ""),
description: operation.description ? String(operation.description) : undefined,
tags: Array.isArray(operation.tags) ? operation.tags.map(String) : [],
});
}
}
return ops;
}
/**
* Parses `docs/openapi.yaml` and returns:
* - `paths`: all operations keyed by `"METHOD /path"`
* - `areas`: operations grouped by SkillArea (api skills only)
*
* Reads the file synchronously so it can be called from both server context
* and standalone scripts without async machinery.
*/
export function parseOpenapi(): ParsedOpenapi {
const yamlPath = path.resolve(process.cwd(), "docs", "openapi.yaml");
let rawContent: string;
try {
rawContent = fs.readFileSync(yamlPath, "utf-8");
} catch (err) {
throw new Error(
`openapiParser: could not read ${yamlPath}. ` +
`Run from project root. Underlying error: ${err instanceof Error ? err.message : String(err)}`
);
}
const doc = yaml.load(rawContent) as Record<string, any>;
if (!doc || typeof doc !== "object") {
throw new Error("openapiParser: parsed YAML is not an object");
}
const pathsObj = doc.paths ?? {};
const operations = extractOperations(pathsObj);
const paths = new Map<string, OpenapiPath>();
const areas = new Map<SkillArea, OpenapiPath[]>();
for (const op of operations) {
const key = `${op.method} ${op.path}`;
paths.set(key, op);
const area = resolveArea(op.path);
if (area) {
if (!areas.has(area)) {
areas.set(area, []);
}
areas.get(area)!.push(op);
}
}
return { paths, areas };
}
/**
* Returns endpoint strings for a given SkillArea, suitable for `AgentSkill.endpoints`.
* Format: `"GET /api/providers/{id}"`.
*/
export function getEndpointsForArea(area: SkillArea): string[] {
const { areas } = parseOpenapi();
const ops = areas.get(area) ?? [];
return ops.map((op) => `${op.method} ${op.path}`);
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
export const SkillCategorySchema = z.enum(["api", "cli"]);
export const AgentSkillSchema = z.object({
id: z.string().regex(/^[a-z][a-z0-9-]*$/),
name: z.string().min(1).max(100),
description: z.string().min(1).max(2000),
category: SkillCategorySchema,
area: z.string().min(1).max(50),
endpoints: z.array(z.string()).optional(),
cliCommands: z.array(z.string()).optional(),
icon: z.string().optional(),
isEntry: z.boolean().optional(),
isNew: z.boolean().optional(),
rawUrl: z.string().url(),
githubUrl: z.string().url(),
});
export const SkillCoverageSchema = z.object({
api: z.object({ have: z.number().int().nonnegative(), total: z.literal(23) }),
cli: z.object({ have: z.number().int().nonnegative(), total: z.literal(20) }),
totalSkills: z.number().int().nonnegative(),
generatedAt: z.string().datetime(),
});
export const ListQuerySchema = z.object({
category: SkillCategorySchema.optional(),
area: z.string().optional(),
});
export const GenerateBodySchema = z.object({
dryRun: z.boolean().default(true),
prune: z.boolean().default(false),
onlyIds: z.array(z.string()).optional(),
});
+101
View File
@@ -0,0 +1,101 @@
export type SkillCategory = "api" | "cli" | "config";
export type SkillArea =
// API areas (22)
| "auth"
| "providers"
| "models"
| "combos-routing"
| "api-keys"
| "usage-logs"
| "budget"
| "settings"
| "proxies"
| "cache"
| "compression"
| "context-rtk"
| "resilience"
| "cli-tools"
| "tunnels"
| "sync-cloud"
| "db-backups"
| "webhooks"
| "mcp"
| "agents-a2a"
| "version-manager"
| "inference"
// GitHub skills
| "github-skills"
// Config skills
| "config-codex-cli"
// CLI families (20)
| "cli-serve"
| "cli-health"
| "cli-providers"
| "cli-keys"
| "cli-models"
| "cli-chat"
| "cli-routing"
| "cli-resilience"
| "cli-compression"
| "cli-contexts"
| "cli-cost-usage"
| "cli-mcp"
| "cli-a2a"
| "cli-tunnel"
| "cli-backup-sync"
| "cli-policy-audit"
| "cli-batches"
| "cli-eval"
| "cli-plugins-skills"
| "cli-setup";
export interface AgentSkill {
id: string; // canonical id (e.g. "omni-providers", "cli-serve")
name: string; // human-readable
description: string; // 1-paragraph
category: SkillCategory;
area: SkillArea;
endpoints?: string[]; // e.g. ["POST /api/providers", "GET /api/providers/:id"] (api only)
cliCommands?: string[]; // e.g. ["providers list", "providers test", "providers rotate"] (cli only)
icon?: string; // Material symbol name
isEntry?: boolean; // "start here" tag
isNew?: boolean; // "new" tag
rawUrl: string; // GitHub raw URL of SKILL.md
githubUrl: string; // GitHub blob URL
}
export interface SkillCoverage {
api: { have: number; total: 23 };
cli: { have: number; total: 20 };
config: { have: number; total: number };
totalSkills: number; // sum
generatedAt: string; // ISO datetime
}
export interface SkillCatalogEntry extends AgentSkill {
// No additional fields; alias for AgentSkill at catalog-level.
}
export interface SkillMarkdown {
id: string;
frontmatter: { name: string; description: string };
body: string; // raw markdown after frontmatter
source: "filesystem" | "github" | "generated";
fetchedAt: string; // ISO
}
export interface GeneratorOptions {
dryRun: boolean; // default true
prune: boolean; // default false
outputDir?: string; // default "skills/"
onlyIds?: string[]; // regenerate only these
}
export interface GeneratorReport {
generated: string[]; // ids that got new/updated SKILL.md
unchanged: string[]; // ids that already match
pruned: string[]; // ids whose folder was deleted (prune mode)
orphansDetected: string[]; // ids in repo that aren't in catalog (prune dry-run shows these)
errors: Array<{ id: string; error: string }>;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Combo API error helper (T-22.b).
*
* Standardizes the 400/404/409 response shape for `/api/combos/*` routes.
* Every failure surfaces a stable machine-readable `code` token (from
* `src/shared/constants/errorCodes.ts`), a human-readable `message`, an
* optional `details` payload, and the current `requestId` for log
* correlation. The prior shape returned `{ error: <string|object> }` with
* no `code` field, which forced clients to string-match English error
* messages. See `plans/2026-06-23-omniroute-v3.8.34-deep-audit.md` (Bug #3).
*
* Usage:
* return comboErrorResponse("COMBO_002", 400, { issues: validation.issues });
* return comboErrorResponse("COMBO_005", 400, { reason: "cycle-detected" });
*
* The response is a plain `Response` (not `NextResponse.json`) so it can
* be used from both Next.js route handlers and server-side callers. The
* `x-request-id` header is also attached for downstream log correlation.
*/
import { ERROR_CODES } from "@/shared/constants/errorCodes";
import {
attachRequestIdToResponse,
getRequestId,
} from "@/shared/utils/requestId";
export type ComboErrorCode =
| "COMBO_001" // request body is not valid JSON
| "COMBO_002" // zod schema failure
| "COMBO_003" // composite tier config invalid
| "COMBO_004" // name collision
| "COMBO_005" // DAG cycle / depth overflow
| "COMBO_006" // managed by Quota Share (409)
| "COMBO_007" // not found (404)
| "VALID_001" // generic invalid body
| "VALID_002" // missing required field
| "INTERNAL_001"; // fallback
export interface ComboErrorBody {
error: {
code: ComboErrorCode;
message: string;
category: string;
details?: unknown;
requestId?: string;
};
}
export function buildComboErrorBody(
code: ComboErrorCode,
details?: unknown
): ComboErrorBody {
const def = ERROR_CODES[code] ?? ERROR_CODES.INTERNAL_001;
const requestId = getRequestId();
return {
error: {
code: def.code as ComboErrorCode,
message: def.message,
category: def.category,
...(details !== undefined ? { details } : {}),
...(requestId ? { requestId } : {}),
},
};
}
export function comboErrorResponse(
code: ComboErrorCode,
status?: number,
details?: unknown,
request?: Request
): Response {
const def = ERROR_CODES[code] ?? ERROR_CODES.INTERNAL_001;
const httpStatus = status ?? def.httpStatus;
const body = buildComboErrorBody(code, details);
const response = Response.json(body, { status: httpStatus });
// Attach x-request-id header for downstream consumers. If a request is
// passed, prefer to derive its id (works outside withRequestId scope);
// otherwise the response body already carries requestId when available.
return request ? attachRequestIdToResponse(request, response) : response;
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from "crypto";
export type ApiErrorType = "invalid_request" | "not_found" | "conflict" | "server_error";
interface ApiErrorPayload {
status: number;
message: string;
type?: ApiErrorType;
details?: unknown;
}
export function createErrorResponse(payload: ApiErrorPayload): Response {
const requestId = randomUUID();
const resolvedType =
payload.type ||
(payload.status >= 500
? "server_error"
: payload.status === 404
? "not_found"
: payload.status === 409
? "conflict"
: "invalid_request");
return Response.json(
{
error: {
message: payload.message,
type: resolvedType,
details: payload.details,
},
requestId,
},
{ status: payload.status }
);
}
export function createErrorResponseFromUnknown(
error: unknown,
fallbackMessage = "Unexpected server error"
): Response {
const anyError = error as {
message?: string;
status?: number;
type?: ApiErrorType;
details?: unknown;
};
const status = Number(anyError?.status) || 500;
return createErrorResponse({
status,
message: typeof anyError?.message === "string" ? anyError.message : fallbackMessage,
type: anyError?.type,
details: anyError?.details,
});
}
+390
View File
@@ -0,0 +1,390 @@
import { randomUUID } from "node:crypto";
import { POST as postChatCompletion } from "@/app/api/v1/chat/completions/route";
import { handleValidatedEmbeddingRequestBody } from "@/app/api/v1/embeddings/route";
import { POST as postRerank } from "@/app/api/v1/rerank/route";
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
import { getCustomModels } from "@/lib/localDb";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { withRateLimit } from "@omniroute/open-sse/services/rateLimitManager";
const INTERNAL_ORIGIN = "http://omniroute.internal";
const DEFAULT_TEST_TIMEOUT_MS = 10_000;
const DOLA_PRO_TEST_TIMEOUT_MS = 90_000;
const DOUBAO_WEB_PROVIDER_ID = "doubao-web";
const SLOW_WEB_TEST_MODELS = new Set(["dola-pro"]);
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function getErrorMessage(error: unknown): string {
return sanitizeErrorMessage(error) || "Unknown error";
}
function getErrorName(error: unknown): string {
return error instanceof Error ? error.name : "";
}
function extractUpstreamDetailMessage(value: unknown): string | null {
const record = asRecord(value);
const message = record.message;
if (typeof message === "string" && message.trim()) return message.trim();
const error = record.error;
if (typeof error === "string" && error.trim()) return error.trim();
const errorRecord = asRecord(error);
const nestedMessage = errorRecord.message;
if (typeof nestedMessage === "string" && nestedMessage.trim()) return nestedMessage.trim();
const body = record.body;
if (typeof body === "string" && body.trim()) return body.trim();
return null;
}
function isGenericHttpProviderError(message: string): boolean {
return /\b(?:returned|provider returned)\s+HTTP\s+\d{3}\b/i.test(message);
}
export function extractProviderErrorMessage(body: unknown, fallback: string) {
const record = asRecord(body);
const error = record.error;
if (typeof error === "string" && error.trim()) return error;
const errorRecord = asRecord(error);
const message = errorRecord.message;
const baseMessage = typeof message === "string" && message.trim() ? message.trim() : fallback;
const upstreamMessage = extractUpstreamDetailMessage(record.upstream_details);
if (
upstreamMessage &&
upstreamMessage !== baseMessage &&
(isGenericHttpProviderError(baseMessage) || baseMessage === fallback)
) {
return `${baseMessage}: ${sanitizeErrorMessage(upstreamMessage)}`;
}
return baseMessage;
}
function stripFirstSegment(modelId: string): string | null {
const slashIdx = modelId.indexOf("/");
return slashIdx > 0 ? modelId.slice(slashIdx + 1) : null;
}
function getModelLeafId(modelId: string): string {
const segments = modelId.trim().toLowerCase().split("/").filter(Boolean);
return segments[segments.length - 1] || "";
}
export function resolveModelTestTimeoutMs(
providerId: string,
modelId: string,
requestedTimeoutMs: number = DEFAULT_TEST_TIMEOUT_MS
) {
if (
providerId.trim().toLowerCase() === DOUBAO_WEB_PROVIDER_ID &&
SLOW_WEB_TEST_MODELS.has(getModelLeafId(modelId))
) {
return Math.max(requestedTimeoutMs, DOLA_PRO_TEST_TIMEOUT_MS);
}
return requestedTimeoutMs;
}
async function findCustomModelMetadata(providerId: string, modelId: string) {
try {
const customModels = await getCustomModels(providerId);
if (!Array.isArray(customModels)) return null;
const candidates = new Set([modelId]);
const stripped = stripFirstSegment(modelId);
if (stripped) candidates.add(stripped);
if (modelId.startsWith(`${providerId}/`)) candidates.add(modelId.slice(providerId.length + 1));
return (
customModels.find(
(model: any) => typeof model?.id === "string" && candidates.has(model.id)
) || null
);
} catch {
return null;
}
}
function buildInternalChatRequest(testBody: Record<string, unknown>, signal: AbortSignal) {
return new Request(`${INTERNAL_ORIGIN}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Reuse the existing strict-mode internal bypass for live health checks.
"X-Internal-Test": "combo-health-check",
"X-OmniRoute-No-Cache": "true",
"X-Request-Id": `model-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
signal,
});
}
function buildInternalRerankRequest(testBody: Record<string, unknown>, signal: AbortSignal) {
return new Request(`${INTERNAL_ORIGIN}/v1/rerank`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Test": "combo-health-check",
"X-OmniRoute-No-Cache": "true",
"X-Request-Id": `model-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
signal,
});
}
export function detectTestKind(modelStr: string, customModel: any) {
const supportedEndpoints = Array.isArray(customModel?.supportedEndpoints)
? customModel.supportedEndpoints
: [];
const apiFormat = typeof customModel?.apiFormat === "string" ? customModel.apiFormat : "";
const lowerModel = modelStr.toLowerCase();
const isRerank =
apiFormat === "rerank" ||
supportedEndpoints.includes("rerank") ||
lowerModel.includes("rerank");
const isEmbedding =
!isRerank &&
(apiFormat === "embeddings" ||
supportedEndpoints.includes("embeddings") ||
lowerModel.includes("embedding") ||
lowerModel.includes("bge-") ||
lowerModel.includes("text-embed") ||
lowerModel.includes("jina-clip") ||
lowerModel.includes("colbert"));
return { isRerank, isEmbedding };
}
/**
* Parse a Retry-After header value (seconds-as-number or HTTP-date) into seconds.
* Returns undefined if the value is missing or unparseable.
*/
export function parseRetryAfterHeader(value: string | null | undefined): number | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const num = Number(trimmed);
if (Number.isFinite(num) && num >= 0) {
return Math.ceil(num);
}
const ms = Date.parse(trimmed);
if (Number.isFinite(ms)) {
return Math.max(0, Math.ceil((ms - Date.now()) / 1000));
}
return undefined;
}
export interface RunSingleModelTestOptions {
providerId: string;
modelId: string;
connectionId?: string;
timeoutMs?: number;
}
export interface SingleModelTestResult {
modelId: string;
status: "ok" | "error" | "rate_limited";
latencyMs: number;
responseText?: string;
statusCode?: number;
httpStatus: number;
error?: string;
rateLimited?: boolean;
isTimeout?: boolean;
retryAfter?: number;
}
/**
* Run a single model test. When `connectionId` is provided, wraps the
* upstream call with `withRateLimit` (Bottleneck). Returns a plain
* `SingleModelTestResult` (not an HTTP Response) so the single-test and
* batch-test endpoints can format it differently.
*/
export async function runSingleModelTest(
options: RunSingleModelTestOptions
): Promise<SingleModelTestResult> {
const { providerId, modelId, connectionId, timeoutMs = DEFAULT_TEST_TIMEOUT_MS } = options;
let fullModelStr = modelId;
if (!fullModelStr.includes("/")) {
fullModelStr = `${providerId}/${modelId}`;
}
const effectiveTimeoutMs = resolveModelTestTimeoutMs(providerId, fullModelStr, timeoutMs);
const startTime = Date.now();
const customModel = await findCustomModelMetadata(providerId, fullModelStr);
const { isRerank, isEmbedding } = detectTestKind(fullModelStr, customModel);
const testBody = isRerank
? {
model: fullModelStr,
query: "What is OmniRoute?",
documents: [
"OmniRoute routes AI requests across configured providers.",
"This document is unrelated to the test query.",
],
top_n: 1,
return_documents: false,
}
: buildComboTestRequestBody(fullModelStr, isEmbedding);
// Per-model AbortController. We track whether the timeout fired so we can
// distinguish "rate-limit queue aborted" (withRateLimit threw AbortError
// with no timeout) from "timeout fired and aborted withRateLimit".
const controller = new AbortController();
let timedOut = false;
const timeoutHandle = setTimeout(() => {
timedOut = true;
controller.abort();
}, effectiveTimeoutMs);
const runInner = async (signal: AbortSignal): Promise<Response> => {
if (isEmbedding) {
return handleValidatedEmbeddingRequestBody(
testBody as Record<string, unknown> & { model: string }
);
}
if (isRerank) {
return postRerank(buildInternalRerankRequest(testBody, signal));
}
return postChatCompletion(buildInternalChatRequest(testBody, signal));
};
let res: Response;
try {
if (connectionId) {
res = await withRateLimit(
providerId,
connectionId,
fullModelStr,
(signal) => runInner(signal),
controller.signal
);
} else {
res = await runInner(controller.signal);
}
} catch (error: unknown) {
clearTimeout(timeoutHandle);
const latencyMs = Date.now() - startTime;
const errorName = getErrorName(error);
if (errorName === "AbortError") {
if (timedOut) {
return {
modelId: fullModelStr,
status: "error",
latencyMs,
httpStatus: 500,
error: `Timeout (${Math.round(effectiveTimeoutMs / 1000)}s)`,
isTimeout: true,
};
}
// AbortError without timeout = withRateLimit queue rejection / abort.
// Surface as rate_limited so the batch endpoint can stop the loop.
return {
modelId: fullModelStr,
status: "rate_limited",
latencyMs,
httpStatus: 429,
error: "Rate limited (queue aborted)",
rateLimited: true,
};
}
return {
modelId: fullModelStr,
status: "error",
latencyMs,
httpStatus: 500,
error: getErrorMessage(error),
};
}
clearTimeout(timeoutHandle);
const latencyMs = Date.now() - startTime;
if (res.status === 429) {
const retryAfter = parseRetryAfterHeader(res.headers.get("retry-after"));
let errorMsg = "Rate limited";
try {
const errBody = await res.json();
errorMsg = extractProviderErrorMessage(errBody, res.statusText || errorMsg);
} catch {
errorMsg = res.statusText || errorMsg;
}
return {
modelId: fullModelStr,
status: "rate_limited",
latencyMs,
statusCode: res.status,
httpStatus: res.status,
error: errorMsg,
rateLimited: true,
...(retryAfter !== undefined ? { retryAfter } : {}),
};
}
if (res.ok) {
let responseBody = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const responseText = extractComboTestResponseText(responseBody);
if (isRerank) {
return {
modelId: fullModelStr,
status: "ok",
latencyMs,
httpStatus: 200,
responseText: "[Rerank completed successfully]",
};
}
if (!responseText && !isEmbedding) {
return {
modelId: fullModelStr,
status: "error",
latencyMs,
statusCode: res.status,
httpStatus: 400,
error: "Provider returned HTTP 200 but no text content.",
};
}
return {
modelId: fullModelStr,
status: "ok",
latencyMs,
httpStatus: 200,
responseText,
};
}
let errorMsg = "";
try {
const errBody = await res.json();
errorMsg = extractProviderErrorMessage(errBody, res.statusText);
} catch {
errorMsg = res.statusText;
}
return {
modelId: fullModelStr,
status: "error",
latencyMs,
statusCode: res.status,
httpStatus: res.status,
error: errorMsg,
};
}
+142
View File
@@ -0,0 +1,142 @@
import {
createProxy,
createProxyAndAssign,
deleteProxyById,
getProxyById,
getProxyWhereUsed,
updateProxy,
updateProxyAndAssign,
} from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
async function readJsonBody(request: Request) {
try {
return { ok: true as const, body: await request.json() };
} catch {
return {
ok: false as const,
response: createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
}),
};
}
}
export async function resolveProxyLookupResponse(
searchParams: URLSearchParams,
whereUsedParam: string
): Promise<Response | null> {
const id = searchParams.get("id");
const whereUsed = searchParams.get(whereUsedParam) === "1";
if (id && whereUsed) {
const usage = await getProxyWhereUsed(id);
return Response.json(usage);
}
if (!id) {
return null;
}
const proxy = await getProxyById(id, { includeSecrets: false });
if (!proxy) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(proxy);
}
export async function handleProxyCreate(request: Request) {
const parsed = await readJsonBody(request);
if (!parsed.ok) return parsed.response;
try {
const validation = validateBody(createProxyRegistrySchema, parsed.body);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { assignment, ...proxyFields } = validation.data;
if (assignment) {
const result = await createProxyAndAssign(proxyFields, assignment);
clearDispatcherCache();
return Response.json({ ...result.proxy, assignment: result.assignment }, { status: 201 });
}
const created = await createProxy(proxyFields);
return Response.json(created, { status: 201 });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to create proxy");
}
}
export async function handleProxyUpdate(request: Request) {
const parsed = await readJsonBody(request);
if (!parsed.ok) return parsed.response;
try {
const validation = validateBody(updateProxyRegistrySchema, parsed.body);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { id, assignment, ...changes } = validation.data;
if (assignment) {
const result = await updateProxyAndAssign(id, changes, assignment);
if (!result?.proxy) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
clearDispatcherCache();
return Response.json({ ...result.proxy, assignment: result.assignment });
}
const updated = await updateProxy(id, changes);
if (!updated) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(updated);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update proxy");
}
}
export async function handleProxyDelete(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const force = searchParams.get("force") === "1";
if (!id) {
return createErrorResponse({
status: 400,
message: "id is required",
type: "invalid_request",
});
}
const deleted = await deleteProxyById(id, { force });
if (!deleted) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json({ success: true });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
}
}
+5
View File
@@ -0,0 +1,5 @@
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function requireCliToolsAuth(request: Request): Promise<Response | null> {
return requireManagementAuth(request);
}
+118
View File
@@ -0,0 +1,118 @@
import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { getApiKeyMetadata } from "@/lib/db/apiKeys";
import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth";
import { evaluateAccessTokenAuth } from "@/server/authz/accessTokenAuth";
import {
MANAGE_SCOPE,
hasManageScope as hasManageScopeShared,
} from "@/shared/constants/managementScopes";
export { MANAGE_SCOPE };
/**
* Check whether any of the supplied scopes authorizes management API access.
*
* Re-exported here for backwards compatibility with existing callers. The
* canonical definition lives in `@/shared/constants/managementScopes`.
*/
export function hasManageScope(scopes: string[] = []): boolean {
return hasManageScopeShared(scopes);
}
interface RequireManagementAuthOptions {
alwaysRequireAuth?: boolean;
invalidApiKeyStatus?: 401 | 403;
}
function invalidManagementTokenResponse(options: RequireManagementAuthOptions): Response {
const status = options.invalidApiKeyStatus ?? 403;
return createErrorResponse({
status,
message: status === 401 ? "Invalid API key" : "Invalid management token",
type: "invalid_request",
});
}
export async function requireManagementAuth(
request: Request,
options: RequireManagementAuthOptions = {}
): Promise<Response | null> {
if (!options.alwaysRequireAuth && !(await isAuthRequired(request))) {
return null;
}
if (await isDashboardSessionAuthenticated(request)) {
return null;
}
// CLI machine-id token allows localhost CLI access without an explicit API key.
if (await isCliTokenAuthValid(request)) {
return null;
}
// Scoped CLI access token (remote mode). Intercepted BEFORE the API-key branch:
// these `oma_` tokens are management/CLI credentials, not inference API keys,
// and would otherwise be rejected by isValidApiKey. Same shared evaluation the
// central managementPolicy uses (no drift). Dashboard JWT, the loopback CLI
// token, and manage-scope API keys remain full-access above/below.
const accessVerdict = evaluateAccessTokenAuth(request);
switch (accessVerdict.kind) {
case "ok":
return null;
case "error":
return createErrorResponse({
status: 503,
message: "Service temporarily unavailable",
type: "server_error",
});
case "invalid":
return createErrorResponse({
status: 401,
message: "Invalid or expired access token",
type: "invalid_request",
});
case "insufficient":
return createErrorResponse({
status: 403,
message: `Access token scope '${accessVerdict.have}' is insufficient; '${accessVerdict.need}' required.`,
type: "invalid_request",
});
case "absent":
break; // no oma_ token → fall through to API-key auth
}
// Management auth never honours a URL-borne credential (header-only) — a token
// in the path/query must not authenticate a management route. See #3300 follow-up.
const apiKey = extractApiKey(request, { allowUrl: false });
if (apiKey) {
let meta: Awaited<ReturnType<typeof getApiKeyMetadata>>;
try {
if (!(await isValidApiKey(apiKey))) {
return invalidManagementTokenResponse(options);
}
meta = await getApiKeyMetadata(apiKey);
} catch {
return createErrorResponse({
status: 503,
message: "Service temporarily unavailable",
type: "server_error",
});
}
if (meta && hasManageScope(meta.scopes)) return null;
return createErrorResponse({
status: 403,
message: "API key lacks 'manage' scope. Enable it in the API Keys dashboard.",
type: "invalid_request",
});
}
return createErrorResponse({
status: 401,
message: "Authentication required",
type: "invalid_request",
});
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Extract a human-readable message from a parsed API error body.
*
* OmniRoute API error bodies follow the shape produced by
* `comboErrorResponse`/`buildErrorBody`:
*
* { error: { message: string, details?: Array<{ message?: string }> } }
*
* Field-level validation errors (e.g. COMBO_002) carry the most specific
* text in `error.details[0].message`; other errors carry it in
* `error.message`. This helper prefers the most specific available message
* and falls back to a caller-supplied default when the body is missing,
* malformed, or not an object — so a failed `fetch().json()` (which may be
* `null`) never throws at the call site.
*/
export function resolveServerErrorMessage(body: unknown, fallback: string): string {
if (!body || typeof body !== "object") return fallback;
const error = (body as { error?: unknown }).error;
if (!error || typeof error !== "object") return fallback;
const details = (error as { details?: unknown }).details;
if (Array.isArray(details) && details.length > 0) {
const first = details[0];
if (first && typeof first === "object") {
const detailMessage = (first as { message?: unknown }).message;
if (typeof detailMessage === "string" && detailMessage.length > 0) {
return detailMessage;
}
}
}
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) return message;
return fallback;
}
+233
View File
@@ -0,0 +1,233 @@
import http from "http";
import type { IncomingMessage, ServerResponse } from "http";
import net from "net";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { getApiBridgeTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
const API_BRIDGE_TIMEOUTS = getApiBridgeTimeoutConfig(process.env, (message) => {
console.warn(`[API Bridge] ${message}`);
});
const OPENAI_COMPAT_PATHS = [
/^\/v1(?:\/|$)/,
/^\/chat\/completions(?:\?|$)/,
/^\/responses(?:\?|$)/,
/^\/models(?:\?|$)/,
/^\/codex(?:\/|\?|$)/,
/^\/api\/oauth(?:\/|$)/,
/^\/callback(?:\?|$)/,
];
function isOpenAiCompatiblePath(pathname: string): boolean {
return OPENAI_COMPAT_PATHS.some((pattern) => pattern.test(pathname));
}
function requestWantsStreaming(req: IncomingMessage): boolean {
const accept = String(req.headers.accept || "").toLowerCase();
if (accept.includes("text/event-stream")) return true;
const pathname = (req.url || "/").split("?")[0] || "/";
return /^\/(?:v1\/)?(?:responses|chat\/completions)(?:\/|$)/.test(pathname);
}
function getProxyTimeoutMs(req: IncomingMessage): number {
if (!requestWantsStreaming(req)) return API_BRIDGE_TIMEOUTS.proxyTimeoutMs;
return Math.max(API_BRIDGE_TIMEOUTS.proxyTimeoutMs, API_BRIDGE_TIMEOUTS.serverRequestTimeoutMs);
}
function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void {
const proxyTimeoutMs = getProxyTimeoutMs(req);
const targetReq = http.request(
{
hostname: "127.0.0.1",
port: dashboardPort,
method: req.method,
path: req.url,
headers: {
...req.headers,
host: `127.0.0.1:${dashboardPort}`,
},
timeout: proxyTimeoutMs,
},
(targetRes) => {
const contentType = String(targetRes.headers["content-type"] || "").toLowerCase();
if (contentType.includes("text/event-stream")) {
targetReq.setTimeout(0);
}
res.writeHead(targetRes.statusCode || 502, targetRes.headers);
targetRes.pipe(res);
}
);
targetReq.on("timeout", () => {
targetReq.destroy();
if (res.headersSent) return;
res.writeHead(504, { "content-type": "application/json" });
res.end(
JSON.stringify({
error: "api_bridge_timeout",
detail: `Proxy request timed out after ${proxyTimeoutMs}ms`,
})
);
});
targetReq.on("error", (error) => {
if (res.headersSent) return;
res.writeHead(502, { "content-type": "application/json" });
res.end(
JSON.stringify({
error: "api_bridge_unavailable",
detail: String(error.message || error),
})
);
});
req.on("aborted", () => {
targetReq.destroy();
});
req.pipe(targetReq);
}
function writeUpgradeProxyError(socket: net.Socket, status: number, body: string): void {
if (!socket.writable || socket.destroyed) return;
const buffer = Buffer.from(body, "utf8");
const response = [
`HTTP/1.1 ${status} ${http.STATUS_CODES[status] || "Error"}`,
"Connection: close",
"Content-Type: application/json; charset=utf-8",
`Content-Length: ${buffer.length}`,
"",
"",
].join("\r\n");
socket.write(response);
socket.end(buffer);
}
function proxyUpgrade(
req: IncomingMessage,
socket: net.Socket,
head: Buffer,
dashboardPort: number
) {
const upstream = net.connect(dashboardPort, "127.0.0.1");
upstream.on("connect", () => {
const requestLine = `${req.method || "GET"} ${req.url || "/"} HTTP/${req.httpVersion || "1.1"}`;
const headerLines: string[] = [requestLine];
let wroteHost = false;
for (let index = 0; index < req.rawHeaders.length; index += 2) {
const name = req.rawHeaders[index];
const rawValue = req.rawHeaders[index + 1] || "";
if (name.toLowerCase() === "host") {
headerLines.push(`Host: 127.0.0.1:${dashboardPort}`);
wroteHost = true;
} else {
headerLines.push(`${name}: ${rawValue}`);
}
}
if (!wroteHost) {
headerLines.push(`Host: 127.0.0.1:${dashboardPort}`);
}
upstream.write(`${headerLines.join("\r\n")}\r\n\r\n`);
if (head.length > 0) {
upstream.write(head);
}
socket.pipe(upstream);
upstream.pipe(socket);
});
upstream.on("error", (error) => {
writeUpgradeProxyError(
socket,
502,
JSON.stringify({
error: "api_bridge_upgrade_failed",
detail: String(error.message || error),
})
);
});
socket.on("error", () => {
upstream.destroy();
});
socket.on("close", () => {
upstream.destroy();
});
}
declare global {
var __omnirouteApiBridgeStarted: boolean | undefined;
}
export function initApiBridgeServer(): void {
if (globalThis.__omnirouteApiBridgeStarted) return;
const { apiPort, dashboardPort } = getRuntimePorts();
if (apiPort === dashboardPort) return;
const host = process.env.API_HOST || "127.0.0.1";
const server = http.createServer((req, res) => {
const rawUrl = req.url || "/";
const pathname = rawUrl.split("?")[0] || "/";
if (!isOpenAiCompatiblePath(pathname)) {
res.writeHead(404, { "content-type": "application/json" });
res.end(
JSON.stringify({
error: "not_found",
message: "API port only serves OpenAI-compatible routes.",
})
);
return;
}
proxyRequest(req, res, dashboardPort);
});
server.requestTimeout = API_BRIDGE_TIMEOUTS.serverRequestTimeoutMs;
server.headersTimeout = API_BRIDGE_TIMEOUTS.serverHeadersTimeoutMs;
server.keepAliveTimeout = API_BRIDGE_TIMEOUTS.serverKeepAliveTimeoutMs;
server.setTimeout(API_BRIDGE_TIMEOUTS.serverSocketTimeoutMs);
server.on("upgrade", (req, socket, head) => {
const rawUrl = req.url || "/";
const pathname = rawUrl.split("?")[0] || "/";
if (!isOpenAiCompatiblePath(pathname)) {
writeUpgradeProxyError(
socket,
404,
JSON.stringify({
error: "not_found",
message: "API port only serves OpenAI-compatible routes.",
})
);
return;
}
proxyUpgrade(req, socket, head, dashboardPort);
});
server.on("error", (error: NodeJS.ErrnoException) => {
if (error?.code === "EADDRINUSE") {
console.warn(
`[API Bridge] Port ${apiPort} is already in use. API bridge disabled. (dashboard: ${dashboardPort})`
);
return;
}
console.warn("[API Bridge] Failed to start:", error?.message || error);
});
server.listen(apiPort, host, () => {
globalThis.__omnirouteApiBridgeStarted = true;
console.log(`[API Bridge] Listening on ${host}:${apiPort} -> dashboard:${dashboardPort}`);
});
}
+19
View File
@@ -0,0 +1,19 @@
import { isApiKeyRevealEnabledFlag } from "@/shared/utils/featureFlags";
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
export function isApiKeyRevealEnabled(): boolean {
try {
return isApiKeyRevealEnabledFlag();
} catch {
const raw = String(process.env.ALLOW_API_KEY_REVEAL || "")
.trim()
.toLowerCase();
return ENABLED_VALUES.has(raw);
}
}
export function maskStoredApiKey(key: unknown): string | null {
if (typeof key !== "string") return null;
return key.slice(0, 8) + "****" + key.slice(-4);
}
+564
View File
@@ -0,0 +1,564 @@
/**
* arenaEloSync.ts — Arena AI leaderboard ELO sync engine.
*
* Fetches model intelligence data from the Arena AI leaderboard API
* (https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard) and stores
* normalised task-fit scores in the `model_intelligence` DB table.
*
* Resolution order: user overrides > synced arena ELO > defaults
*
* On by default; opt out via Dashboard Feature Flags or ARENA_ELO_SYNC_ENABLED=false.
*/
import { isArenaEloSyncEnabled } from "@/shared/utils/featureFlags";
import { backupDbFile } from "./db/backup";
import {
bulkUpsertModelIntelligence,
deleteExpiredIntelligence,
deleteModelIntelligenceBySource,
type ModelIntelligenceEntry,
} from "./db/modelIntelligence";
// ─── Types ───────────────────────────────────────────────
/**
* A single model entry from the Arena AI leaderboard.
*/
export interface ArenaModelEntry {
/** Leaderboard rank (1-based). */
rank: number;
/** Model identifier (may include vendor prefix like "anthropic/claude-opus"). */
model: string;
/** Vendor / provider name (e.g. "Anthropic", "OpenAI"). */
vendor: string;
/** ELO score (higher = better). */
score: number;
/** Confidence interval half-width. */
ci: number;
/** Total number of human preference votes. */
votes: number;
/** License type (e.g. "proprietary", "open"). */
license: string;
}
/**
* Metadata + models for a single leaderboard category.
*/
export interface ArenaLeaderboardData {
/** Leaderboard metadata. */
meta: {
/** Leaderboard category name (e.g. "text", "code"). */
leaderboard: string;
/** Total number of models in this leaderboard. */
model_count: number;
};
/** Ranked model entries. */
models: ArenaModelEntry[];
}
/**
* Map of leaderboard category → leaderboard data.
*/
export interface ArenaLeaderboardMap {
[category: string]: ArenaLeaderboardData;
}
/**
* Result of a sync operation.
*/
export interface SyncResult {
/** Whether the sync completed successfully. */
success: boolean;
/** Number of model intelligence entries stored. */
modelCount: number;
/** Source identifier (always "arena_elo"). */
source: string;
/** Error message if sync failed. */
error?: string;
}
/**
* Current status of the Arena ELO sync subsystem.
*/
export interface SyncStatus {
/** Whether periodic sync is enabled via env var. */
enabled: boolean;
/** ISO timestamp of last successful sync, or null. */
lastSync: string | null;
/** Number of models stored in last successful sync. */
lastSyncModelCount: number;
/** ISO timestamp of next scheduled sync, or null. */
nextSync: string | null;
/** Configured sync interval in milliseconds. */
intervalMs: number;
/** Active data sources. */
sources: string[];
}
// ─── Configuration ───────────────────────────────────────
const ARENA_ELO_API_BASE = "https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard";
/** Leaderboard categories to fetch from the Arena API. */
const FETCH_CATEGORIES = ["text", "code"] as const;
/**
* Maps Arena leaderboard categories to OmniRoute task-type categories.
*
* - "text" leaderboard → default, review, documentation, debugging
* - "code" leaderboard → coding
* - "vision" leaderboard is intentionally skipped (not relevant for text fitness)
*/
const CATEGORY_TASK_MAP: Record<string, string[]> = {
text: ["default", "review", "documentation", "debugging"],
code: ["coding"],
};
/**
* Known vendor prefixes to strip from model names.
* E.g. "anthropic/claude-opus-4-6-thinking" → "claude-opus-4-6-thinking"
*/
const VENDOR_PREFIXES = [
"anthropic/",
"openai/",
"google/",
"meta/",
"mistral/",
"deepseek/",
"xai/",
"cohere/",
"qwen/",
"alibaba/",
"nvidia/",
"01-ai/",
"zerox/",
"together/",
"fireworks/",
"perplexity/",
"ai21/",
] as const;
/**
* OmniRoute model aliases: canonical name → known aliases.
* Creates additional DB entries for each alias so that models
* are findable under any name OmniRoute uses internally.
*/
const MODEL_ALIAS_MAP: Record<string, string[]> = {
"claude-opus-4-6-thinking": ["claude-opus-4", "anthropic/claude-opus-4"],
"claude-sonnet-4-5": ["claude-sonnet-4.5", "anthropic/claude-sonnet-4.5"],
"gpt-5.5": ["openai/gpt-5.5", "gpt-5"],
"gemini-3-flash": ["google/gemini-3-flash", "gemini-flash"],
"deepseek-r1": ["deepseek/deepseek-r1", "if/deepseek-r1"],
"kimi-k2-thinking": ["moonshot/kimi-k2", "qw/kimi-k2"],
"qwen3-coder-plus": ["qw/qwen3-coder-plus", "alibaba/qwen3-coder"],
"llama-4": ["meta/llama-4", "llama4"],
};
/** Votes threshold for "high" confidence. */
const HIGH_CONFIDENCE_VOTES = 5000;
/** Votes threshold for "medium" confidence. */
const MEDIUM_CONFIDENCE_VOTES = 1000;
/** Intelligence entry expiration: 7 days after sync. */
const EXPIRY_DAYS = 7;
const parsedInterval = parseInt(process.env.ARENA_ELO_SYNC_INTERVAL || "86400", 10);
const SYNC_INTERVAL_MS =
Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000;
// ─── Periodic sync state ─────────────────────────────────
let syncTimer: ReturnType<typeof setInterval> | null = null;
let lastSyncTime: string | null = null;
let lastSyncModelCount = 0;
let activeSyncIntervalMs = SYNC_INTERVAL_MS;
let firstSyncDone = false;
let syncInProgress = false;
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getEffectiveArenaEloSyncEnabled(): boolean {
try {
return isArenaEloSyncEnabled();
} catch (error) {
console.warn(
`[ARENA_ELO_SYNC] Failed to resolve ARENA_ELO_SYNC_ENABLED feature flag: ${getErrorMessage(
error
)}`
);
return process.env.ARENA_ELO_SYNC_ENABLED !== "false";
}
}
// ─── Model name normalization ────────────────────────────
/**
* Normalize a model name from the Arena leaderboard.
*
* Lowercases the name and strips known vendor prefixes
* (e.g. "anthropic/claude-opus-4" → "claude-opus-4").
*
* @param rawName - The raw model name from the API response.
* @returns The cleaned, lowercase model name.
*/
export function normalizeModelName(rawName: string): string {
let name = rawName.toLowerCase();
for (const prefix of VENDOR_PREFIXES) {
if (name.startsWith(prefix)) {
name = name.slice(prefix.length);
break;
}
}
return name;
}
// ─── Core: Fetch ─────────────────────────────────────────
/**
* Fetch leaderboards from the Arena AI API for all configured categories.
*
* Fetches "text" and "code" leaderboards concurrently and returns
* a map of category → leaderboard data.
*
* @returns Map of leaderboard category to its data.
* @throws If all category fetches fail (individual failures are logged and skipped).
*/
export async function fetchArenaLeaderboards(): Promise<ArenaLeaderboardMap> {
const result: ArenaLeaderboardMap = {};
const errors: string[] = [];
const fetches = FETCH_CATEGORIES.map(async (category) => {
const url = `${ARENA_ELO_API_BASE}?name=${category}`;
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(
`Arena API fetch failed for "${category}" [${response.status}]: ${response.statusText}`
);
}
const text = await response.text();
try {
result[category] = JSON.parse(text) as ArenaLeaderboardData;
} catch {
throw new Error(
`Arena API returned invalid JSON for "${category}" (${text.slice(0, 100)}...)`
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[ARENA_ELO_SYNC] Failed to fetch "${category}" leaderboard: ${message}`);
errors.push(message);
}
});
await Promise.all(fetches);
if (Object.keys(result).length === 0) {
throw new Error(`All Arena leaderboard fetches failed: ${errors.join("; ")}`);
}
return result;
}
// ─── Core: Transform ─────────────────────────────────────
/**
* Compute confidence level based on vote count.
*
* @param votes - Number of human preference votes.
* @returns "high" (≥5000), "medium" (≥1000), or "low" (<1000).
*/
function computeConfidence(votes: number): "high" | "medium" | "low" {
if (votes >= HIGH_CONFIDENCE_VOTES) return "high";
if (votes >= MEDIUM_CONFIDENCE_VOTES) return "medium";
return "low";
}
/**
* Transform raw Arena leaderboard data into model intelligence entries.
*
* For each leaderboard category, normalizes ELO scores into task-fit values
* in the range [0.4, 0.98] using the formula:
*
* taskFit = 0.4 + 0.58 * ((elo - minElo) / (maxElo - minElo || 1))
*
* This ensures scores never reach 0 or 1, leaving room for user overrides.
* Models with fewer than 100 votes are marked as confidence="low".
*
* Leaderboard categories are mapped to OmniRoute task types:
* - "text" → default, review, documentation, debugging
* - "code" → coding
*
* Known OmniRoute model aliases are also expanded into additional entries.
*
* @param data - Map of leaderboard category → Arena leaderboard data.
* @returns Array of model intelligence entries ready for DB upsert.
*/
export function transformToModelIntelligence(
data: ArenaLeaderboardMap
): Array<Omit<ModelIntelligenceEntry, "syncedAt">> {
const entries: Array<Omit<ModelIntelligenceEntry, "syncedAt">> = [];
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000).toISOString();
for (const [category, leaderboard] of Object.entries(data)) {
const taskCategories = CATEGORY_TASK_MAP[category];
if (!taskCategories) continue;
const models = Array.isArray(leaderboard.models) ? leaderboard.models : [];
if (models.length === 0) continue;
// Compute ELO range for normalization
const eloScores = models.map((m) => m.score);
const minElo = Math.min(...eloScores);
const maxElo = Math.max(...eloScores);
const eloRange = maxElo - minElo || 1;
for (const model of models) {
const normalizedModel = normalizeModelName(model.model);
const confidence = computeConfidence(model.votes);
const taskFit = 0.4 + 0.58 * ((model.score - minElo) / eloRange);
for (const taskCategory of taskCategories) {
const entry: Omit<ModelIntelligenceEntry, "syncedAt"> = {
model: normalizedModel,
category: taskCategory,
source: "arena_elo",
score: Math.round(taskFit * 10000) / 10000,
eloRaw: model.score,
confidence,
expiresAt,
};
entries.push(entry);
// Expand known aliases
const aliases = MODEL_ALIAS_MAP[normalizedModel];
if (aliases) {
for (const alias of aliases) {
entries.push({
...entry,
model: alias,
});
}
}
}
}
}
return entries;
}
// ─── Main sync function ──────────────────────────────────
/**
* Fetch, transform, and store Arena ELO intelligence data.
*
* Pipeline: delete expired → fetch leaderboards → transform → bulk upsert.
* All errors are caught and logged — sync is never fatal.
*
* @param dryRun - If true, fetches and transforms but does not write to DB.
* @returns Sync result with model count and success status.
*/
export async function syncArenaElo(dryRun = false): Promise<SyncResult> {
if (syncInProgress) {
return {
success: false,
modelCount: 0,
source: "arena_elo",
error: "Sync already in progress",
};
}
syncInProgress = true;
try {
// Backup DB before first sync (same pattern as pricingSync)
if (!firstSyncDone && !dryRun) {
backupDbFile("pre-arena-elo-sync");
firstSyncDone = true;
}
// Clean up stale entries before writing new ones
if (!dryRun) {
try {
deleteExpiredIntelligence();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[ARENA_ELO_SYNC] Failed to delete expired intelligence: ${message}`);
}
}
const leaderboards = await fetchArenaLeaderboards();
const entries = transformToModelIntelligence(leaderboards);
if (!dryRun && entries.length > 0) {
try {
bulkUpsertModelIntelligence(entries);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[ARENA_ELO_SYNC] Failed to bulk upsert intelligence: ${message}`);
return {
success: false,
modelCount: 0,
source: "arena_elo",
error: message,
};
}
}
if (!dryRun) {
lastSyncTime = new Date().toISOString();
lastSyncModelCount = entries.length;
}
const countLabel = dryRun ? "would sync" : "synced";
console.log(
`[ARENA_ELO_SYNC] ${countLabel} ${entries.length} model intelligence entries from Arena leaderboards`
);
return {
success: true,
modelCount: entries.length,
source: "arena_elo",
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn("[ARENA_ELO_SYNC] Sync failed:", message);
return {
success: false,
modelCount: 0,
source: "arena_elo",
error: message,
};
} finally {
syncInProgress = false;
}
}
// ─── Clear synced data ───────────────────────────────────
/**
* Clear all synced intelligence data (arena_elo source).
*
* Iterates through all arena_elo entries and deletes them one by one,
* since the DB module provides per-key deletion. This is used by the
* DELETE /api/intelligence/sync endpoint.
*/
export function clearSyncedIntelligence(): void {
const deleted = deleteModelIntelligenceBySource("arena_elo");
console.log(`[ARENA_ELO_SYNC] Cleared ${deleted} arena_elo intelligence entries`);
}
// ─── Periodic sync ───────────────────────────────────────
/**
* Start periodic Arena ELO sync (non-blocking).
*
* Performs an initial sync immediately, then schedules periodic syncs
* at the configured interval. The timer is unref'd so it won't prevent
* the Node.js process from exiting.
*
* @param intervalMs - Override interval in milliseconds (defaults to env or 86400s).
*/
function startPeriodicSync(intervalMs?: number): void {
if (syncTimer) return; // Already running
const interval = intervalMs ?? SYNC_INTERVAL_MS;
activeSyncIntervalMs = interval;
console.log(`[ARENA_ELO_SYNC] Starting periodic sync every ${interval / 1000}s`);
// Initial sync (non-blocking)
syncArenaElo()
.then((result) => {
if (result.success) {
console.log(
`[ARENA_ELO_SYNC] Initial sync complete: ${result.modelCount} model intelligence entries`
);
}
})
.catch((err) => {
console.warn(
"[ARENA_ELO_SYNC] Initial sync error:",
err instanceof Error ? err.message : err
);
});
syncTimer = setInterval(() => {
syncArenaElo()
.then((result) => {
if (result.success) {
console.log(`[ARENA_ELO_SYNC] Periodic sync complete: ${result.modelCount} entries`);
}
})
.catch((err) => {
console.warn(
"[ARENA_ELO_SYNC] Periodic sync error:",
err instanceof Error ? err.message : err
);
});
}, interval);
// Prevent the timer from keeping the process alive
if (syncTimer && typeof syncTimer === "object" && "unref" in syncTimer) {
(syncTimer as { unref?: () => void }).unref?.();
}
}
/**
* Stop periodic Arena ELO sync and clean up the timer.
*/
export function stopArenaEloSync(): void {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
console.log("[ARENA_ELO_SYNC] Periodic sync stopped");
}
}
/**
* Get the current Arena ELO sync status.
*
* @returns Sync status including enabled flag, last sync time, model count,
* next scheduled sync time, interval, and active sources.
*/
export function getArenaEloSyncStatus(): SyncStatus {
const enabled = getEffectiveArenaEloSyncEnabled();
return {
enabled,
lastSync: lastSyncTime,
lastSyncModelCount,
nextSync:
syncTimer && lastSyncTime
? new Date(new Date(lastSyncTime).getTime() + activeSyncIntervalMs).toISOString()
: null,
intervalMs: activeSyncIntervalMs,
sources: ["arena_elo"],
};
}
// ─── Init (called from server-init.ts) ───────────────────
/**
* Initialize Arena ELO sync if enabled via feature flag configuration.
*
* Reads `ARENA_ELO_SYNC_ENABLED` (default: true; set to `false` to opt out)
* through the feature flag resolver, so DB overrides from the dashboard apply.
* When enabled, starts periodic sync with the interval from `ARENA_ELO_SYNC_INTERVAL`
* (default: 86400 seconds / daily).
*
* All errors during initialization or the initial sync are caught and logged
* — initialization is never fatal.
*/
export async function initArenaEloSync(): Promise<boolean> {
if (!getEffectiveArenaEloSyncEnabled()) {
console.log(
"[ARENA_ELO_SYNC] Disabled by the effective ARENA_ELO_SYNC_ENABLED feature flag. Enable it from Dashboard Feature Flags, unset the env var, or set it to true to enable."
);
return false;
}
startPeriodicSync();
return true;
}
+69
View File
@@ -0,0 +1,69 @@
export interface ActivityIconSpec {
/** Material Symbols icon name (e.g. "extension"). */
icon: string;
/** i18n key under namespace `activity.eventVerb.*` for the human verb. */
i18nKeyVerb: string;
}
export const ACTIVITY_ICONS: Record<string, ActivityIconSpec> = {
// providers
"provider.credentials.created": { icon: "extension", i18nKeyVerb: "providerCredentialsCreated" },
"provider.credentials.applied": {
icon: "check_circle",
i18nKeyVerb: "providerCredentialsApplied",
},
"provider.credentials.updated": { icon: "edit", i18nKeyVerb: "providerCredentialsUpdated" },
"provider.credentials.revoked": {
icon: "extension_off",
i18nKeyVerb: "providerCredentialsRevoked",
},
"provider.credentials.batch_revoked": {
icon: "extension_off",
i18nKeyVerb: "providerCredentialsBatchRevoked",
},
"provider.credentials.batch_updated": {
icon: "edit",
i18nKeyVerb: "providerCredentialsBatchUpdated",
},
"provider.credentials.bulk_created": {
icon: "extension",
i18nKeyVerb: "providerCredentialsBulkCreated",
},
"provider.credentials.bulk_imported": {
icon: "upload",
i18nKeyVerb: "providerCredentialsBulkImported",
},
"provider.credentials.imported": { icon: "upload", i18nKeyVerb: "providerCredentialsImported" },
"provider.validation.ssrf_blocked": { icon: "block", i18nKeyVerb: "providerSsrfBlocked" },
// auth
"auth.login.success": { icon: "login", i18nKeyVerb: "authLoginSuccess" },
"auth.login.error": { icon: "error", i18nKeyVerb: "authLoginError" },
"auth.login.failed": { icon: "error", i18nKeyVerb: "authLoginFailed" },
"auth.login.locked": { icon: "lock", i18nKeyVerb: "authLoginLocked" },
"auth.login.misconfigured": { icon: "warning", i18nKeyVerb: "authLoginMisconfigured" },
"auth.login.setup_required": { icon: "warning", i18nKeyVerb: "authLoginSetupRequired" },
"auth.logout.success": { icon: "logout", i18nKeyVerb: "authLogoutSuccess" },
// sync
"sync.token.created": { icon: "sync", i18nKeyVerb: "syncTokenCreated" },
"sync.token.revoked": { icon: "sync_disabled", i18nKeyVerb: "syncTokenRevoked" },
// settings
"settings.update": { icon: "settings", i18nKeyVerb: "settingsUpdate" },
"settings.update_failed": { icon: "warning", i18nKeyVerb: "settingsUpdateFailed" },
// service
"service.reveal_api_key": { icon: "visibility", i18nKeyVerb: "serviceRevealApiKey" },
// quota
"quota.pool.created": { icon: "pie_chart", i18nKeyVerb: "quotaPoolCreated" },
"quota.pool.updated": { icon: "edit_note", i18nKeyVerb: "quotaPoolUpdated" },
"quota.pool.deleted": { icon: "delete", i18nKeyVerb: "quotaPoolDeleted" },
"quota.plan.updated": { icon: "fact_check", i18nKeyVerb: "quotaPlanUpdated" },
"quota.store.driver_changed": { icon: "storage", i18nKeyVerb: "quotaStoreDriverChanged" },
};
export function getActivityIcon(action: string): ActivityIconSpec {
return ACTIVITY_ICONS[action] ?? { icon: "info", i18nKeyVerb: "genericEvent" };
}
+57
View File
@@ -0,0 +1,57 @@
/**
* HIGH_LEVEL_ACTIONS — allowlist of audit events that appear in the Activity feed.
*
* IMPORTANT: This list is intentionally aligned with the REAL action strings emitted
* by `logAuditEvent()` calls throughout the repository. Do NOT use "clean" or invented
* names — always grep for `logAuditEvent` in the codebase before adding or renaming
* an entry here. If the upstream emitter name changes, update both the emitter and
* this list atomically.
*
* Last synced: 2026-05 (B/G3 gap-closure). Source of truth: grep logAuditEvent repo.
*/
export const HIGH_LEVEL_ACTIONS = [
// providers / connections — ALINHADO COM `logAuditEvent` real
"provider.credentials.created",
"provider.credentials.applied",
"provider.credentials.updated",
"provider.credentials.revoked",
"provider.credentials.batch_revoked",
"provider.credentials.batch_updated",
"provider.credentials.bulk_created",
"provider.credentials.bulk_imported",
"provider.credentials.imported",
"provider.validation.ssrf_blocked",
// auth
"auth.login.success",
"auth.login.error",
"auth.login.failed",
"auth.login.locked",
"auth.login.misconfigured",
"auth.login.setup_required",
"auth.logout.success",
// sync tokens
"sync.token.created",
"sync.token.revoked",
// settings — alinhado (plural)
"settings.update",
"settings.update_failed",
// service operations
"service.reveal_api_key",
// quota sharing (B26 — adicionados por F8)
"quota.pool.created",
"quota.pool.updated",
"quota.pool.deleted",
"quota.plan.updated",
"quota.store.driver_changed",
] as const;
const SET: ReadonlySet<string> = new Set<string>(HIGH_LEVEL_ACTIONS);
export function isHighLevelAction(action: string): boolean {
return SET.has(action);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Timeline helpers — pure functions for grouping AuditLogEntry arrays by day
* and producing human-readable relative timestamps.
*
* No I/O, no side-effects — safe to import in both server and client contexts.
*/
import type { AuditLogEntry } from "@/lib/compliance/index";
export interface DayGroup {
/** YYYY-MM-DD in local server time */
dayKey: string;
/** "today" | "yesterday" | ISO date string for older days */
label: "today" | "yesterday" | string;
entries: AuditLogEntry[];
}
/**
* Returns YYYY-MM-DD for a given ISO timestamp (using local server time).
*/
function toDayKey(iso: string): string {
const d = new Date(iso);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Returns YYYY-MM-DD for a given epoch ms (local server time).
*/
function epochToDayKey(ms: number): string {
const d = new Date(ms);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Groups audit log entries by calendar day (local server time), sorted
* descending (most recent day first). Each group has a human label:
* - "today" for the current day
* - "yesterday" for the previous day
* - ISO date string "YYYY-MM-DD" for older days
*
* @param entries - Flat list of audit entries (order not assumed)
* @param referenceNowMs - Override for "now" (ms since epoch). Defaults to Date.now().
*/
export function groupByDay(entries: AuditLogEntry[], referenceNowMs?: number): DayGroup[] {
if (!entries.length) return [];
const nowMs = referenceNowMs ?? Date.now();
const todayKey = epochToDayKey(nowMs);
const yesterdayKey = epochToDayKey(nowMs - 24 * 60 * 60 * 1000);
// Sort descending by timestamp
const sorted = [...entries].sort((a, b) => {
const ta = a.timestamp ?? "";
const tb = b.timestamp ?? "";
if (ta > tb) return -1;
if (ta < tb) return 1;
// tiebreak by id desc
const ia = typeof a.id === "number" ? a.id : 0;
const ib = typeof b.id === "number" ? b.id : 0;
return ib - ia;
});
const groupMap = new Map<string, AuditLogEntry[]>();
const dayOrder: string[] = [];
for (const entry of sorted) {
const dk = toDayKey(entry.timestamp ?? "");
if (!groupMap.has(dk)) {
groupMap.set(dk, []);
dayOrder.push(dk);
}
groupMap.get(dk)!.push(entry);
}
return dayOrder.map((dk) => {
let label: string;
if (dk === todayKey) {
label = "today";
} else if (dk === yesterdayKey) {
label = "yesterday";
} else {
label = dk;
}
return { dayKey: dk, label, entries: groupMap.get(dk)! };
});
}
/**
* Returns a human-readable relative time string for the given ISO timestamp.
*
* @param iso - ISO 8601 timestamp string
* @param locale - "en" or "pt-BR"
* @param referenceNowMs - Override for "now" (ms since epoch). Defaults to Date.now().
*/
export function relativeTime(
iso: string,
locale: "en" | "pt-BR",
referenceNowMs?: number
): string {
const nowMs = referenceNowMs ?? Date.now();
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) {
return locale === "pt-BR" ? "agora há pouco" : "just now";
}
const diffMs = nowMs - then;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (locale === "pt-BR") {
if (diffSec < 60) return "agora há pouco";
if (diffMin < 60) return `${diffMin} min`;
if (diffHour < 24) return `${diffHour} h`;
if (diffDay === 1) return "ontem";
return `${diffDay} dias`;
}
// English
if (diffSec < 60) return "just now";
if (diffMin < 60) return `${diffMin} min ago`;
if (diffHour < 24) return `${diffHour} h ago`;
if (diffDay === 1) return "yesterday";
return `${diffDay} days ago`;
}
+117
View File
@@ -0,0 +1,117 @@
import bcrypt from "bcryptjs";
import { getSettings, updateSettings } from "@/lib/db/settings";
const BCRYPT_HASH_PATTERN = /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/;
const MANAGEMENT_PASSWORD_SALT_ROUNDS = 12;
// Well-known placeholder shipped in `.env.example` (INITIAL_PASSWORD=CHANGEME). Bootstrapping
// with it leaves the dashboard open to anyone, so we warn loudly on boot (Seg2 hardening).
const INSECURE_DEFAULT_PASSWORDS = new Set(["CHANGEME"]);
type JsonRecord = Record<string, unknown>;
type MigrationSource = "stored_hash" | "stored_plaintext" | "env" | "missing";
interface EnsureManagementPasswordOptions {
initialPassword?: string | null;
logger?: Pick<Console, "log"> & Partial<Pick<Console, "warn">>;
settings?: JsonRecord;
source?: string;
}
export interface EnsuredManagementPassword {
hash: string | null;
migrated: boolean;
settings: JsonRecord;
source: MigrationSource;
}
function getInitialPasswordValue(value: string | null | undefined) {
return typeof value === "string" && value.length > 0 ? value : null;
}
export function getStoredManagementPassword(settings: JsonRecord | null | undefined) {
return typeof settings?.password === "string" ? settings.password : "";
}
export function hasManagementPasswordConfigured(settings: JsonRecord | null | undefined) {
return (
getStoredManagementPassword(settings).length > 0 ||
getInitialPasswordValue(process.env.INITIAL_PASSWORD) !== null
);
}
export function isBcryptHash(value: unknown): value is string {
return typeof value === "string" && BCRYPT_HASH_PATTERN.test(value);
}
export async function hashManagementPassword(password: string) {
return bcrypt.hash(password, MANAGEMENT_PASSWORD_SALT_ROUNDS);
}
export async function verifyManagementPassword(password: string, hash: string) {
if (!isBcryptHash(hash)) return false;
return bcrypt.compare(password, hash);
}
export async function ensurePersistentManagementPasswordHash(
options: EnsureManagementPasswordOptions = {}
): Promise<EnsuredManagementPassword> {
const settings = options.settings ?? ((await getSettings()) as JsonRecord);
const storedPassword = getStoredManagementPassword(settings);
if (isBcryptHash(storedPassword)) {
return {
hash: storedPassword,
migrated: false,
settings,
source: "stored_hash",
};
}
const bootstrapPassword =
storedPassword ||
getInitialPasswordValue(options.initialPassword ?? process.env.INITIAL_PASSWORD);
if (bootstrapPassword && INSECURE_DEFAULT_PASSWORDS.has(bootstrapPassword)) {
const warn = options.logger?.warn?.bind(options.logger) ?? console.warn;
warn(
'[AUTH][SECURITY] Management password is set to the well-known default "CHANGEME" ' +
"(INITIAL_PASSWORD in .env.example). Anyone can sign in to the dashboard with it — " +
"change it immediately via the dashboard or a strong INITIAL_PASSWORD."
);
}
if (!bootstrapPassword) {
return {
hash: null,
migrated: false,
settings,
source: "missing",
};
}
const passwordHash = await hashManagementPassword(bootstrapPassword);
const updates: JsonRecord = { password: passwordHash };
if (settings.setupComplete !== true) {
updates.setupComplete = true;
}
if (!storedPassword) {
updates.requireLogin = true;
}
const nextSettings = (await updateSettings(updates)) as JsonRecord;
if (options.logger) {
const context = options.source ? ` during ${options.source}` : "";
const migrationSource = storedPassword ? "stored plaintext password" : "INITIAL_PASSWORD";
options.logger.log(`[AUTH] Migrated ${migrationSource} to bcrypt hash${context}`);
}
return {
hash: getStoredManagementPassword(nextSettings) || passwordHash,
migrated: true,
settings: nextSettings,
source: storedPassword ? "stored_plaintext" : "env",
};
}
+130
View File
@@ -0,0 +1,130 @@
import type { CostEstimate } from "./types";
import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints";
import { DEFAULT_PRICING } from "@/shared/constants/pricing";
/**
* NOTE on pricing import shape:
*
* `src/shared/constants/pricing.ts` exports `DEFAULT_PRICING` — a nested object:
* DEFAULT_PRICING[providerAlias][modelId] = { input, output, cached, reasoning, cache_creation }
*
* All rates are in USD per 1 million tokens.
*
* `getPrice()` below iterates over all providers to find an entry matching the
* model id (exact or case-insensitive). This is intentional: callers of
* `estimateBatchCost` only know the model id, not the provider alias.
*/
type PricingEntry = { input: number; output: number };
function getPrice(
model: string
): { input: number; output: number; src: CostEstimate["pricingSource"] } | null {
const table = DEFAULT_PRICING as Record<string, Record<string, unknown>>;
// Pass 1: exact match across all providers
for (const providerModels of Object.values(table)) {
if (typeof providerModels !== "object" || providerModels === null) continue;
const entry = (providerModels as Record<string, unknown>)[model];
if (
entry &&
typeof entry === "object" &&
typeof (entry as PricingEntry).input === "number" &&
typeof (entry as PricingEntry).output === "number"
) {
const e = entry as PricingEntry;
return { input: e.input, output: e.output, src: "exact-match" };
}
}
// Pass 2: case-insensitive alias match
const lower = model.toLowerCase();
for (const providerModels of Object.values(table)) {
if (typeof providerModels !== "object" || providerModels === null) continue;
for (const [key, val] of Object.entries(providerModels as Record<string, unknown>)) {
if (
key.toLowerCase() === lower &&
val &&
typeof val === "object" &&
typeof (val as PricingEntry).input === "number" &&
typeof (val as PricingEntry).output === "number"
) {
const e = val as PricingEntry;
return { input: e.input, output: e.output, src: "alias-match" };
}
}
}
return null;
}
const BATCH_DISCOUNT = 0.5;
const DEFAULT_OUTPUT_TOKENS = 256;
const CHARS_PER_TOKEN = 4; // heuristic: ~4 UTF-8 chars per token
/**
* Estimate the cost of submitting a JSONL batch.
*
* - Input tokens: heuristic `Math.ceil(bodyStr.length / 4)` per request.
* - Output tokens: `min(max_tokens || 256, 1024)` per request.
* - Batch discount: -50% on both input and output (OpenAI / Anthropic batch APIs).
* - Prices from `DEFAULT_PRICING` in `src/shared/constants/pricing.ts`.
* - If model is not in the table, costs are 0 and a warning is added.
*
* Always label results as "estimated (~)" in the UI.
*/
export function estimateBatchCost(input: {
jsonl: string;
model: string;
endpoint: SupportedBatchEndpoint;
}): CostEstimate {
const lines = input.jsonl.split(/\r?\n/).filter((l) => l.trim().length > 0);
let inputTokens = 0;
let outputTokens = 0;
for (const line of lines) {
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
// Support both OpenAI shape (body) and Anthropic shape (params)
const bodyObj = (parsed.body ?? parsed.params ?? {}) as Record<string, unknown>;
const bodyStr = JSON.stringify(bodyObj);
inputTokens += Math.ceil(bodyStr.length / CHARS_PER_TOKEN);
const rawMaxTokens = (bodyObj as Record<string, unknown>).max_tokens;
const maxTokens =
typeof rawMaxTokens === "number" && rawMaxTokens > 0 ? rawMaxTokens : DEFAULT_OUTPUT_TOKENS;
outputTokens += Math.min(maxTokens, 1024);
} catch {
// Skip malformed lines — validateJsonl will flag these separately
}
}
const price = getPrice(input.model);
const warnings: string[] = [];
let pricingSource: CostEstimate["pricingSource"] = "fallback";
let inputRate = 0;
let outputRate = 0;
if (price) {
inputRate = price.input;
outputRate = price.output;
pricingSource = price.src;
} else {
warnings.push(`model "${input.model}" not found in pricing table — cost shown as $0 (fallback)`);
}
// Rates are per 1 million tokens
const syncCostUsd = (inputTokens * inputRate + outputTokens * outputRate) / 1_000_000;
const batchCostUsd = syncCostUsd * BATCH_DISCOUNT;
return {
model: input.model,
totalRequests: lines.length,
estimatedInputTokens: inputTokens,
estimatedOutputTokens: outputTokens,
syncCostUsd,
batchCostUsd,
savingsUsd: syncCostUsd - batchCostUsd,
pricingSource,
warnings,
};
}
+248
View File
@@ -0,0 +1,248 @@
import { csvToJsonlInputSchema, type CsvToJsonlInput } from "./schemas";
/**
* RFC 4180 minimal CSV parser.
*
* Supports: quoted fields, escaped double-quotes (""), CRLF/LF line endings,
* inline newlines inside quoted fields.
* Does NOT strip BOM — callers should strip before invoking if needed.
*/
function parseCsvRow(line: string): string[] {
const out: string[] = [];
let buf = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
// Escaped quote: "" inside a quoted field
if (ch === '"' && line[i + 1] === '"') {
buf += '"';
i++;
continue;
}
if (ch === '"') {
inQuotes = false;
continue;
}
buf += ch;
} else {
if (ch === '"') {
inQuotes = true;
continue;
}
if (ch === ",") {
out.push(buf);
buf = "";
continue;
}
buf += ch;
}
}
out.push(buf);
return out;
}
/**
* Split CSV text into logical lines, preserving inline newlines inside quoted fields.
* Handles CRLF and LF; does not collapse multiple empty lines.
*/
function splitLines(s: string): string[] {
const out: string[] = [];
let buf = "";
let inQuotes = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') {
if (inQuotes && s[i + 1] === '"') {
// Escaped quote inside quoted field: add both and advance
buf += '""';
i++;
continue;
}
inQuotes = !inQuotes;
buf += ch;
continue;
}
if ((ch === "\n" || ch === "\r") && !inQuotes) {
if (ch === "\r" && s[i + 1] === "\n") i++;
if (buf.length > 0) out.push(buf);
buf = "";
continue;
}
buf += ch;
}
if (buf.length > 0) out.push(buf);
return out;
}
// ── Prototype-pollution guard ─────────────────────────────────────────────────
/**
* Keys that are forbidden as object property names (CWE-915 — prototype pollution).
* A legitimate CSV mapping path cannot contain these: the Zod schema
* `wizardCsvMappingSchema` validates paths come from a controlled UI dropdown;
* we add this explicit denylist as defence-in-depth.
*/
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
/**
* Write `value` to `obj[key]` using Object.defineProperty instead of bracket
* assignment. Object.defineProperty bypasses the prototype-chain write path,
* preventing prototype-pollution attacks (semgrep rule
* javascript.lang.security.audit.prototype-pollution-assignment).
*/
function safePropSet(obj: object, key: string | number, value: unknown): void {
Object.defineProperty(obj, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
/**
* Set a nested value in `target` using a dot/bracket path such as:
* "custom_id"
* "body.max_tokens"
* "body.messages[0].content"
*
* Silently ignores malformed path segments or forbidden key names.
*/
function setByPath(target: Record<string, unknown>, path: string, value: unknown): void {
const tokens: Array<string | number> = [];
for (const part of path.split(".")) {
const match = part.match(/^([^[]+)((?:\[\d+\])*)$/);
if (!match) continue;
const key = match[1];
if (FORBIDDEN_KEYS.has(key)) return; // reject forbidden key
tokens.push(key);
for (const idx of match[2].match(/\d+/g) ?? []) {
tokens.push(Number(idx));
}
}
if (tokens.length === 0) return;
let cur: Record<string, unknown> = target;
for (let i = 0; i < tokens.length - 1; i++) {
const k = tokens[i];
const next = tokens[i + 1];
if (!Object.prototype.hasOwnProperty.call(cur, k) || cur[k] == null) {
const child: unknown = typeof next === "number" ? [] : Object.create(null);
safePropSet(cur, k, child);
}
const nextCur = Object.prototype.hasOwnProperty.call(cur, k) ? cur[k] : undefined;
if (nextCur == null || typeof nextCur !== "object") return; // bail if tree navigation failed
cur = nextCur as Record<string, unknown>;
}
const lastKey = tokens.at(-1)!;
if (typeof lastKey === "string" && FORBIDDEN_KEYS.has(lastKey)) return;
safePropSet(cur, lastKey, value);
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Convert a CSV string + column mapping into a JSONL batch request file.
*
* - Validates input via `csvToJsonlInputSchema` (Zod).
* - Parses CSV per RFC 4180 (quoted fields, escaped quotes, CRLF/LF).
* - Auto-fills `role: "user"` when mapping includes a content path but no role path.
* - Coerces max_tokens / temperature to numbers.
* - Skips rows missing `custom_id` or content; records them in `errors`.
*/
export function csvToJsonl(rawInput: CsvToJsonlInput): {
jsonl: string;
rowsParsed: number;
rowsSkipped: number;
errors: Array<{ row: number; reason: string }>;
} {
const input = csvToJsonlInputSchema.parse(rawInput);
// Strip UTF-8 BOM if present so the first header cell parses cleanly
const normalizedCsv = input.csv.replace(/^/, "");
const lines = splitLines(normalizedCsv);
if (lines.length < 2) {
return {
jsonl: "",
rowsParsed: 0,
rowsSkipped: 0,
errors: [{ row: 0, reason: "CSV has no data rows" }],
};
}
const headers = parseCsvRow(lines[0]);
const out: string[] = [];
const errors: Array<{ row: number; reason: string }> = [];
let skipped = 0;
for (let r = 1; r < lines.length; r++) {
const cells = parseCsvRow(lines[r]);
// Skip blank rows
if (cells.length === 0 || cells.every((c) => c.trim() === "")) {
skipped++;
continue;
}
const request: Record<string, unknown> = {
method: input.defaults.method,
url: input.defaults.url,
body: Object.assign(Object.create(null) as Record<string, unknown>, {
model: input.defaults.model,
}),
};
let hasContent = false;
let hasCustomId = false;
for (let c = 0; c < headers.length; c++) {
const header = headers[c];
const path = input.mapping[header];
if (!path) continue;
const raw = cells[c] ?? "";
if (path === "custom_id") {
if (raw.trim().length === 0) {
errors.push({ row: r + 1, reason: "custom_id is empty" });
continue;
}
request.custom_id = raw;
hasCustomId = true;
} else if (path.startsWith("body.messages[") && path.endsWith(".content")) {
setByPath(request, path, raw);
// Auto-fill role: "user" unless the mapping already maps a role for this slot
const rolePath = path.replace(".content", ".role");
if (!Object.values(input.mapping).includes(rolePath)) {
setByPath(request, rolePath, "user");
}
hasContent = true;
} else if (path === "body.input" || path === "body.prompt") {
setByPath(request, path, raw);
hasContent = true;
} else if (path.startsWith("body.")) {
// Coerce numeric fields (max_tokens, temperature, top_p, etc.)
const num = Number(raw);
setByPath(request, path, Number.isFinite(num) && raw.trim() !== "" ? num : raw);
}
}
if (!hasContent || !hasCustomId) {
skipped++;
errors.push({ row: r + 1, reason: "missing content or custom_id" });
continue;
}
out.push(JSON.stringify(request));
}
return {
jsonl: out.join("\n") + (out.length > 0 ? "\n" : ""),
rowsParsed: out.length,
rowsSkipped: skipped,
errors,
};
}
+53
View File
@@ -0,0 +1,53 @@
import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints";
type BatchRouteHandler = (request: Request) => Promise<Response> | Response;
const handlerLoaders: Record<SupportedBatchEndpoint, () => Promise<BatchRouteHandler>> = {
"/v1/responses": async () => (await import("@/app/api/v1/responses/route")).POST,
"/v1/chat/completions": async () => (await import("@/app/api/v1/chat/completions/route")).POST,
"/v1/embeddings": async () => (await import("@/app/api/v1/embeddings/route")).POST,
"/v1/completions": async () => (await import("@/app/api/v1/completions/route")).POST,
"/v1/moderations": async () => (await import("@/app/api/v1/moderations/route")).POST,
"/v1/images/generations": async () =>
(await import("@/app/api/v1/images/generations/route")).POST,
"/v1/videos/generations": async () =>
(await import("@/app/api/v1/videos/generations/route")).POST,
};
const handlerCache = new Map<SupportedBatchEndpoint, BatchRouteHandler>();
async function getHandler(endpoint: SupportedBatchEndpoint): Promise<BatchRouteHandler> {
const cached = handlerCache.get(endpoint);
if (cached) return cached;
const handler = await handlerLoaders[endpoint]();
handlerCache.set(endpoint, handler);
return handler;
}
async function dispatchBatchApiRequest({
endpoint,
body,
apiKey,
}: {
endpoint: SupportedBatchEndpoint;
body: Record<string, unknown>;
apiKey?: string | null;
}): Promise<Response> {
const headers = new Headers({ "Content-Type": "application/json" });
if (apiKey) {
headers.set("Authorization", `Bearer ${apiKey}`);
}
const handler = await getHandler(endpoint);
const request = new Request(`http://localhost${endpoint}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return await handler(request);
}
export const dispatch = {
dispatchBatchApiRequest,
};
+77
View File
@@ -0,0 +1,77 @@
import type { RetryPlan } from "./types";
/**
* Parse the error JSONL (output of a failed batch) and return a Set of custom_ids
* that had errors.
*
* Each line of the error file has the shape:
* { "id": "...", "custom_id": "...", "error": { "code": "...", "message": "..." } }
*/
function parseErrorIds(errorJsonl: string): Set<string> {
const out = new Set<string>();
for (const line of errorJsonl.split(/\r?\n/)) {
if (line.trim().length === 0) continue;
try {
const parsed = JSON.parse(line);
if (typeof parsed?.custom_id === "string" && parsed.custom_id.length > 0) {
out.add(parsed.custom_id);
}
} catch {
// Skip lines that are not valid JSON — they are not error entries
}
}
return out;
}
/**
* Build a retry plan: filter the original input JSONL to keep only the requests
* whose `custom_id` appears in the error JSONL.
*
* This helper is **pure** — it does not fetch, read files, or write to disk.
* The caller is responsible for:
* 1. Fetching `GET /v1/files/{inputFileId}/content` → inputJsonl
* 2. Fetching `GET /v1/files/{errorFileId}/content` → errorJsonl
* 3. Calling buildRetryPlan({ inputJsonl, errorJsonl })
* 4. Uploading newJsonl via `POST /v1/files`
* 5. Creating a new batch via `POST /v1/batches`
*/
export function buildRetryPlan(input: {
inputJsonl: string;
errorJsonl: string;
}): RetryPlan {
const failed = parseErrorIds(input.errorJsonl);
if (failed.size === 0) {
return {
failedCustomIds: [],
retriableLines: 0,
skippedLines: 0,
newJsonl: "",
};
}
const out: string[] = [];
let skipped = 0;
for (const line of input.inputJsonl.split(/\r?\n/)) {
if (line.trim().length === 0) continue;
try {
const parsed = JSON.parse(line);
if (typeof parsed?.custom_id === "string" && failed.has(parsed.custom_id)) {
out.push(line);
} else {
skipped++;
}
} catch {
// Skip invalid JSON lines — they won't retry cleanly anyway
skipped++;
}
}
return {
failedCustomIds: Array.from(failed),
retriableLines: out.length,
skippedLines: skipped,
newJsonl: out.join("\n") + (out.length > 0 ? "\n" : ""),
};
}
+38
View File
@@ -0,0 +1,38 @@
import { z } from "zod";
import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
import { BATCH_SUPPORTED_PROVIDERS } from "./types";
export const wizardDestinationSchema = z.object({
provider: z.enum(BATCH_SUPPORTED_PROVIDERS),
endpoint: z.enum(SUPPORTED_BATCH_ENDPOINTS),
model: z.string().min(1).max(128),
});
export const wizardCsvMappingSchema = z
.record(z.string().max(256), z.string().max(256))
.refine((m) => Object.values(m).includes("custom_id"), {
message: "CSV mapping must include a column → custom_id",
})
.refine(
(m) =>
Object.values(m).some((v) =>
v.startsWith("body.messages[") || v === "body.input" || v === "body.prompt"
),
{ message: "CSV mapping must produce request body content" }
);
// Used only for client-side parsing — backend keeps using v1BatchCreateSchema.
// Zod 4 note: `.required()` was removed from `defaults`; in Zod 4 it strips
// `.default()` making the field required with no fallback — breaking `method`.
// All non-optional fields are already required by z.object without `.required()`.
export const csvToJsonlInputSchema = z.object({
csv: z.string().min(1),
mapping: wizardCsvMappingSchema,
defaults: z.object({
model: z.string().min(1),
method: z.literal("POST").default("POST"),
url: z.enum(SUPPORTED_BATCH_ENDPOINTS),
}),
});
export type CsvToJsonlInput = z.infer<typeof csvToJsonlInputSchema>;
+78
View File
@@ -0,0 +1,78 @@
import { SUPPORTED_BATCH_ENDPOINTS, type SupportedBatchEndpoint }
from "@/shared/constants/batchEndpoints";
// ── Wizard state ─────────────────────────────────────────────────────────────
export interface WizardDestination {
provider: "openai" | "anthropic" | "gemini";
endpoint: SupportedBatchEndpoint;
model: string;
}
export type WizardInputKind = "jsonl" | "csv";
export interface WizardCsvMapping {
// CSV header name → JSONL field path.
// Supported paths: "custom_id", "body.messages[0].content",
// "body.messages[0].role", "body.max_tokens",
// "body.temperature", "body.model" (defaults to wizard.model).
[csvColumn: string]: string;
}
export interface WizardInput {
kind: WizardInputKind;
fileName: string | null;
rawContent: string | null; // utf-8 text (read via FileReader)
csvMapping?: WizardCsvMapping; // only when kind === "csv"
}
// ── Validation result ────────────────────────────────────────────────────────
export interface JsonlLineError {
lineNumber: number; // 1-based
reason: string; // user-facing, short
field?: string; // optional path of offending field
}
export interface ValidationResult {
ok: boolean;
totalLines: number;
sampledLines: number; // how many lines actually inspected
uniqueCustomIds: number;
duplicateCustomIds: string[]; // up to first 10
errors: JsonlLineError[]; // up to first 50
preview: unknown[]; // first 5 parsed request bodies
byteSize: number;
}
// ── Cost estimate ────────────────────────────────────────────────────────────
export interface CostEstimate {
model: string;
totalRequests: number;
estimatedInputTokens: number;
estimatedOutputTokens: number;
syncCostUsd: number; // baseline
batchCostUsd: number; // syncCost * 0.5
savingsUsd: number; // syncCost - batchCost
pricingSource: "exact-match" | "alias-match" | "fallback";
warnings: string[]; // e.g. "model not in pricing table"
}
// ── Retry plan ───────────────────────────────────────────────────────────────
export interface RetryPlan {
failedCustomIds: string[]; // from error_file_id
retriableLines: number;
skippedLines: number;
newJsonl: string; // ready to upload
}
// ── Provider catalog (D16 / D17) ─────────────────────────────────────────────
export const BATCH_SUPPORTED_PROVIDERS = ["openai", "anthropic", "gemini"] as const;
// ── Re-exports ───────────────────────────────────────────────────────────────
export type { SupportedBatchEndpoint };
export { SUPPORTED_BATCH_ENDPOINTS };
+154
View File
@@ -0,0 +1,154 @@
import type { JsonlLineError, ValidationResult } from "./types";
import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints";
const OPENAI_LIKE = new Set([
"/v1/chat/completions",
"/v1/embeddings",
"/v1/completions",
"/v1/moderations",
"/v1/images/generations",
"/v1/videos/generations",
"/v1/responses",
]);
interface LineResult {
customId?: string;
errors: JsonlLineError[];
parsed?: unknown;
}
function validateOneLine(
raw: string,
endpoint: SupportedBatchEndpoint,
lineNo: number
): LineResult {
const errors: JsonlLineError[] = [];
if (raw.trim().length === 0) return { errors };
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw);
} catch {
return { errors: [{ lineNumber: lineNo, reason: "invalid JSON" }] };
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return { errors: [{ lineNumber: lineNo, reason: "line is not a JSON object" }] };
}
if (typeof parsed.custom_id !== "string" || parsed.custom_id.length === 0) {
errors.push({ lineNumber: lineNo, reason: "custom_id missing or empty", field: "custom_id" });
}
// Anthropic native batch shape: { custom_id, params }
// OpenAI batch shape: { custom_id, method, url, body }
if ("params" in parsed) {
if (typeof parsed.params !== "object" || parsed.params === null) {
errors.push({
lineNumber: lineNo,
reason: "params must be an object (Anthropic batch shape)",
field: "params",
});
}
} else {
if (parsed.method !== "POST") {
errors.push({ lineNumber: lineNo, reason: "method must be POST", field: "method" });
}
if (typeof parsed.url !== "string" || !OPENAI_LIKE.has(parsed.url)) {
errors.push({
lineNumber: lineNo,
reason: `url must be one of the supported batch endpoints`,
field: "url",
});
} else if (parsed.url !== endpoint) {
errors.push({
lineNumber: lineNo,
reason: `url "${parsed.url}" differs from batch endpoint "${endpoint}"`,
field: "url",
});
}
if (typeof parsed.body !== "object" || parsed.body === null || Array.isArray(parsed.body)) {
errors.push({ lineNumber: lineNo, reason: "body must be an object", field: "body" });
}
}
return {
customId: typeof parsed.custom_id === "string" ? parsed.custom_id : undefined,
errors,
parsed: errors.length === 0 ? parsed : undefined,
};
}
/**
* Validate a JSONL string (OpenAI or Anthropic batch request format).
*
* For large files (> 5 MB) the caller should use sampling: pass maxLinesToInspect=1000
* and tailLinesToInspect=100. For smaller files pass the defaults or a very high number.
*
* @param content - Full JSONL text (UTF-8 string)
* @param opts - endpoint to validate against; optional sampling limits
* @returns ValidationResult with errors, duplicates, preview, and byte size
*/
export function validateJsonl(
content: string,
opts: {
endpoint: SupportedBatchEndpoint;
maxLinesToInspect?: number;
tailLinesToInspect?: number;
}
): ValidationResult {
const maxHead = opts.maxLinesToInspect ?? 1000;
const maxTail = opts.tailLinesToInspect ?? 100;
// Strip UTF-8 BOM if present (Windows-saved files) so first line parses cleanly
const normalized = content.replace(/^/, "");
const lines = normalized.split(/\r?\n/);
// Drop trailing empty lines
while (lines.length > 0 && lines.at(-1)!.trim() === "") lines.pop();
const total = lines.length;
// Build index set: head N + tail M (deduplicated, in order)
const indexSet = new Set<number>();
for (let i = 0; i < Math.min(maxHead, total); i++) indexSet.add(i);
for (let i = Math.max(maxHead, total - maxTail); i < total; i++) indexSet.add(i);
const indices = Array.from(indexSet).sort((a, b) => a - b);
const customIds = new Set<string>();
const duplicates = new Set<string>();
const errors: JsonlLineError[] = [];
const preview: unknown[] = [];
for (const i of indices) {
const result = validateOneLine(lines[i], opts.endpoint, i + 1);
if (result.customId) {
if (customIds.has(result.customId)) {
duplicates.add(result.customId);
} else {
customIds.add(result.customId);
}
}
for (const err of result.errors) {
if (errors.length < 50) errors.push(err);
}
if (preview.length < 5 && result.parsed != null) {
preview.push(result.parsed);
}
}
const byteSize = new TextEncoder().encode(content).length;
return {
ok: errors.length === 0 && duplicates.size === 0,
totalLines: total,
sampledLines: indices.length,
uniqueCustomIds: customIds.size,
duplicateCustomIds: Array.from(duplicates).slice(0, 10),
errors,
preview,
byteSize,
};
}
+33
View File
@@ -0,0 +1,33 @@
export interface BenchmarkResult {
name: string;
duration: number;
opsPerSecond: number;
memory: number;
success: boolean;
}
export async function runBenchmarks(): Promise<BenchmarkResult[]> {
const results: BenchmarkResult[] = [];
results.push({
name: "memory_retrieval",
duration: 0,
opsPerSecond: 0,
memory: 0,
success: true,
});
results.push({
name: "skill_execution",
duration: 0,
opsPerSecond: 0,
memory: 0,
success: true,
});
return results;
}
export function formatBenchmarkReport(results: BenchmarkResult[]): string {
return results.map((r) => `${r.name}: ${r.opsPerSecond.toFixed(2)} ops/s`).join("\n");
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Helper used by the `OMNIROUTE_BUILD_PROFILE=minimal` stubs to surface a
* consistent "this feature was compiled out" error. Routes that depend on a
* stubbed module should catch the error and return HTTP 503 with a clear
* message; we don't want the bundle to silently fail.
*
* See docs/security/SOCKET_DEV_FINDINGS.md for the build profile rationale.
*/
export class FeatureDisabledError extends Error {
readonly featureName: string;
constructor(featureName: string) {
super(
`Feature "${featureName}" is disabled in this build (OMNIROUTE_BUILD_PROFILE=minimal). ` +
`Install the full omniroute artifact instead of omniroute-secure if you need this feature.`
);
this.name = "FeatureDisabledError";
this.featureName = featureName;
}
}
export function featureDisabledError(featureName: string): FeatureDisabledError {
return new FeatureDisabledError(featureName);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Cache Control Settings
*
* Provides cached access to cache control settings for performance.
* Settings are fetched once and cached to avoid repeated DB hits.
*/
import { getSettings } from "./db/settings";
import type { CacheControlMode } from "@omniroute/open-sse/utils/cacheControlPolicy";
let cachedSettings: CacheControlMode | null = null;
export async function getCacheControlSettings(): Promise<CacheControlMode> {
if (cachedSettings !== null) {
return cachedSettings;
}
const settings = await getSettings();
cachedSettings = (settings.alwaysPreserveClientCache as CacheControlMode) || "auto";
return cachedSettings;
}
export function invalidateCacheControlSettingsCache() {
cachedSettings = null;
}
+220
View File
@@ -0,0 +1,220 @@
/**
* LRU Cache Layer — FASE-08 LLM Proxy Advanced
*
* In-memory LRU cache for LLM prompt/response pairs.
* Uses content hashing for cache keys to handle semantic deduplication.
* Memory-optimized with byte-based limits.
*
* @module lib/cacheLayer
*/
import crypto from "crypto";
/**
* @typedef {Object} CacheEntry
* @property {string} key - Cache key (hash)
* @property {*} value - Cached value
* @property {number} createdAt - Timestamp
* @property {number} ttl - TTL in ms
* @property {number} size - Approximate size in bytes
* @property {number} hits - Number of times this entry was accessed
*/
const DEFAULT_MAX_ENTRIES = 50;
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
const DEFAULT_TTL = 300000;
export class LRUCache {
/** @type {Map<string, CacheEntry>} */
#cache = new Map();
#maxSize;
#maxBytes;
#defaultTTL;
#currentSize = 0;
#currentBytes = 0;
#stats = { hits: 0, misses: 0, evictions: 0 };
/**
* @param {Object} options
* @param {number} [options.maxSize=50] - Max number of entries (reduced for memory)
* @param {number} [options.maxBytes=2097152] - Max bytes (default: 2MB)
* @param {number} [options.defaultTTL=300000] - Default TTL in ms (5 min)
*/
constructor(options: { maxSize?: number; maxBytes?: number; defaultTTL?: number } = {}) {
this.#maxSize = options.maxSize ?? DEFAULT_MAX_ENTRIES;
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.#defaultTTL = options.defaultTTL ?? DEFAULT_TTL;
}
/**
* Generate a cache key from input.
* @param {Object} params - Parameters to hash
* @returns {string} Cache key
*/
static generateKey(params: Record<string, unknown>) {
const normalized = JSON.stringify(params, Object.keys(params).sort());
return crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16);
}
/**
* Get a value from the cache.
* @param {string} key
* @returns {*|undefined}
*/
get(key: string) {
const entry = this.#cache.get(key);
if (!entry) {
this.#stats.misses++;
return undefined;
}
if (Date.now() - entry.createdAt > entry.ttl) {
this.#deleteEntry(key, entry);
this.#stats.misses++;
return undefined;
}
this.#cache.delete(key);
entry.hits++;
this.#cache.set(key, entry);
this.#stats.hits++;
return entry.value;
}
/**
* Set a value in the cache.
* @param {string} key
* @param {*} value
* @param {number} [ttl] - Override default TTL
*/
set(key: string, value: unknown, ttl?: number) {
const entrySize = this.#estimateSize(value);
if (this.#cache.has(key)) {
const oldEntry = this.#cache.get(key)!;
this.#currentBytes -= oldEntry.size || 0;
this.#currentSize--;
this.#cache.delete(key);
}
while (
(this.#currentSize >= this.#maxSize || this.#currentBytes + entrySize > this.#maxBytes) &&
this.#cache.size > 0
) {
const oldestKey = this.#cache.keys().next().value;
const oldestEntry = this.#cache.get(oldestKey);
if (oldestEntry) {
this.#deleteEntry(oldestKey, oldestEntry);
}
this.#stats.evictions++;
}
const entry = {
key,
value,
createdAt: Date.now(),
ttl: ttl ?? this.#defaultTTL,
size: entrySize,
hits: 0,
};
this.#cache.set(key, entry);
this.#currentSize++;
this.#currentBytes += entrySize;
}
/**
* Estimate size of a value in bytes.
*/
#estimateSize(value: unknown): number {
try {
return JSON.stringify(value).length * 2;
} catch {
return 1024;
}
}
/**
* Delete an entry and update counters.
*/
#deleteEntry(key: string, entry: { size?: number }) {
this.#cache.delete(key);
this.#currentSize--;
this.#currentBytes -= entry.size || 0;
if (this.#currentBytes < 0) this.#currentBytes = 0;
}
/**
* Check if a key exists (without promoting it).
* @param {string} key
* @returns {boolean}
*/
has(key: string) {
const entry = this.#cache.get(key);
if (!entry) return false;
if (Date.now() - entry.createdAt > entry.ttl) {
this.#deleteEntry(key, entry);
return false;
}
return true;
}
/**
* Delete a specific key.
* @param {string} key
* @returns {boolean}
*/
delete(key: string) {
const entry = this.#cache.get(key);
if (entry) {
this.#deleteEntry(key, entry);
return true;
}
return false;
}
/** Clear the entire cache. */
clear() {
this.#cache.clear();
this.#currentSize = 0;
this.#currentBytes = 0;
}
/** @returns {{ size: number, maxSize: number, bytes: number, maxBytes: number, hits: number, misses: number, evictions: number, hitRate: number }} */
getStats() {
const total = this.#stats.hits + this.#stats.misses;
return {
size: this.#currentSize,
maxSize: this.#maxSize,
bytes: this.#currentBytes,
maxBytes: this.#maxBytes,
...this.#stats,
hitRate: total > 0 ? (this.#stats.hits / total) * 100 : 0,
};
}
}
// ─── Prompt Cache Singleton ─────────────────
let promptCache: LRUCache | null = null;
/**
* Get the global prompt cache instance.
* @param {Object} [options]
* @returns {LRUCache}
*/
export function getPromptCache(
options?: { maxSize?: number; maxBytes?: number; defaultTTL?: number } & Record<string, unknown>
) {
if (!promptCache) {
promptCache = new LRUCache({
maxSize: parseInt(process.env.PROMPT_CACHE_MAX_SIZE || "50", 10),
maxBytes: parseInt(process.env.PROMPT_CACHE_MAX_BYTES || String(2 * 1024 * 1024), 10),
defaultTTL: parseInt(process.env.PROMPT_CACHE_TTL_MS || "300000", 10),
...options,
});
}
return promptCache;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* openrouterCatalog.ts — Feature 09
* Catálogo OpenRouter com cache persistente em arquivo JSON local.
*
* - TTL configurável via env OPENROUTER_CATALOG_TTL_MS (default: 24h)
* - Fallback stale-if-error: retorna último snapshot válido se fetch falhar
* - Atualização oportunista em background (não bloqueia o caller)
*/
import fs from "fs";
import path from "path";
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models";
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
function getTTL(): number {
const env = process.env.OPENROUTER_CATALOG_TTL_MS;
return env ? parseInt(env, 10) : DEFAULT_TTL_MS;
}
function getCacheFilePath(): string {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), "data");
const cacheDir = path.join(dataDir, "cache");
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
return path.join(cacheDir, "openrouter-catalog.json");
}
interface CatalogEntry {
id: string;
name?: string;
description?: string;
context_length?: number;
pricing?: {
prompt?: string;
completion?: string;
image?: string;
request?: string;
};
top_provider?: {
max_completion_tokens?: number;
is_moderated?: boolean;
};
architecture?: {
modality?: string;
input_modalities?: string[];
output_modalities?: string[];
tokenizer?: string;
instruct_type?: string;
};
supported_parameters?: string[];
created?: number;
}
interface CacheFile {
fetchedAt: string;
data: CatalogEntry[];
}
/** Read cached catalog from disk. Returns null if not found or unparseable. */
function readCache(): CacheFile | null {
const filePath = getCacheFilePath();
try {
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf8");
return JSON.parse(raw) as CacheFile;
} catch {
return null;
}
}
/** Write catalog to disk cache. */
function writeCache(data: CatalogEntry[]): void {
const filePath = getCacheFilePath();
const cache: CacheFile = {
fetchedAt: new Date().toISOString(),
data,
};
try {
fs.writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf8");
} catch (err) {
console.warn("[OpenRouterCatalog] Failed to write cache:", err);
}
}
/** Fetch fresh catalog from OpenRouter API. */
async function fetchFromAPI(): Promise<CatalogEntry[]> {
const res = await fetch(OPENROUTER_API_URL, {
headers: {
"User-Agent": "OmniRoute/2.0",
Accept: "application/json",
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
throw new Error(`OpenRouter API returned ${res.status}: ${res.statusText}`);
}
const json = (await res.json()) as { data?: CatalogEntry[] };
const models = Array.isArray(json.data) ? json.data : [];
return models;
}
/**
* Get OpenRouter model catalog.
*
* Returns { data, stale, cachedAt } where:
* - data: list of models
* - stale: true if data is from a stale cache (fetch failed)
* - cachedAt: ISO string of when the data was cached (null if fresh fetch)
*/
export async function getOpenRouterCatalog(): Promise<{
data: CatalogEntry[];
stale: boolean;
cachedAt: string | null;
fromCache: boolean;
}> {
const ttl = getTTL();
const cache = readCache();
const now = Date.now();
// Return cached data if still within TTL
if (cache && cache.fetchedAt) {
const age = now - new Date(cache.fetchedAt).getTime();
if (age < ttl) {
return {
data: cache.data,
stale: false,
cachedAt: cache.fetchedAt,
fromCache: true,
};
}
}
// Cache expired or missing — attempt fresh fetch
try {
const data = await fetchFromAPI();
writeCache(data);
return { data, stale: false, cachedAt: null, fromCache: false };
} catch (err) {
console.warn("[OpenRouterCatalog] Fetch failed, using stale cache:", err);
// Stale-if-error: return old cache if available
if (cache) {
return {
data: cache.data,
stale: true,
cachedAt: cache.fetchedAt,
fromCache: true,
};
}
// No cache at all — return empty with error signal
return { data: [], stale: true, cachedAt: null, fromCache: false };
}
}
/**
* Force-refresh the catalog cache (ignores TTL).
* Used by admin endpoints and manual refresh actions.
*/
export async function refreshOpenRouterCatalog(): Promise<{
data: CatalogEntry[];
ok: boolean;
error?: string;
}> {
try {
const data = await fetchFromAPI();
writeCache(data);
return { data, ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return { data: [], ok: false, error };
}
}
@@ -0,0 +1,92 @@
import path from "node:path";
import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "../../shared/services/cliRuntime";
import { getModelSyncInternalBaseUrl } from "../../shared/services/modelSyncScheduler";
import { isFeatureFlagEnabled } from "../../shared/utils/featureFlags";
type SyncResult =
| {
ok: true;
written: number;
skipped: number;
reason: string;
}
| {
ok: false;
skipped: true;
reason: string;
};
function isAutoSyncEnabled() {
// Opt-in, default OFF. Backed by the OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES feature flag
// (resolver precedence: DB/dashboard-toggle override > env > default "false"), so a
// provider model sync never silently writes ~/.claude/profiles/<name>/settings.json
// unless the operator turned it on — via the providers-dashboard toggle or the env var.
return isFeatureFlagEnabled("OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES");
}
function forwardAuthHeaders(request: Request): Record<string, string> {
const headers: Record<string, string> = {};
const cookie = request.headers.get("cookie");
const authorization = request.headers.get("authorization");
if (cookie) headers.cookie = cookie;
if (authorization) headers.authorization = authorization;
return headers;
}
export async function autoSyncClaudeProfilesFromLiveCatalog(
request: Request,
reason: string
): Promise<SyncResult> {
if (!isAutoSyncEnabled()) {
return { ok: false, skipped: true, reason: "disabled" };
}
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return { ok: false, skipped: true, reason: writeGuard };
}
const internalBase = getModelSyncInternalBaseUrl().replace(/\/$/, "");
const res = await fetch(`${internalBase}/v1/models`, {
headers: forwardAuthHeaders(request),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
return { ok: false, skipped: true, reason: `catalog_http_${res.status}` };
}
const body = await res.json();
const candidateModels = Array.isArray(body) ? body : body.data || body.models || [];
const models = Array.isArray(candidateModels) ? candidateModels : [];
// Claude Code has no flat config file; its per-tool config lives at ~/.claude/settings.json,
// so getCliConfigPaths("claude") exposes `settings` (NOT `config`). The profiles are written
// under <claudeHome>/profiles/<name>/settings.json, where claudeHome = dirname(settings).
const claudePaths = getCliConfigPaths("claude");
if (!claudePaths?.settings) {
return { ok: false, skipped: true, reason: "claude_config_path_unavailable" };
}
const claudeHome = path.dirname(claudePaths.settings);
// Each generated profile points ANTHROPIC_BASE_URL at the OmniRoute this server serves.
// Strip a trailing /v1 (Claude Code appends the version segment itself).
const profileBaseUrl = internalBase.replace(/\/v1$/, "");
// Reuse the CLI generator so automatic sync and `omniroute setup-claude` stay
// behaviorally identical.
// @ts-ignore - bin CLI modules are shipped as ESM JavaScript, without TS declarations.
const { syncClaudeProfilesFromModels } =
await import("../../../bin/cli/commands/setup-claude.mjs");
const result = await syncClaudeProfilesFromModels(models, {
claudeHome,
baseUrl: profileBaseUrl,
});
return {
ok: true,
written: result.written,
skipped: result.skipped,
reason,
};
}
@@ -0,0 +1,80 @@
import path from "node:path";
import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "../../shared/services/cliRuntime";
import { getModelSyncInternalBaseUrl } from "../../shared/services/modelSyncScheduler";
import { isFeatureFlagEnabled } from "../../shared/utils/featureFlags";
type SyncResult =
| {
ok: true;
written: number;
skipped: number;
reason: string;
}
| {
ok: false;
skipped: true;
reason: string;
};
function isAutoSyncEnabled() {
// Opt-in, default OFF. Backed by the OMNIROUTE_AUTO_SYNC_CODEX_PROFILES feature flag
// (resolver precedence: DB/dashboard-toggle override > env > default "false"), so a
// provider model sync never silently writes ~/.codex/*.config.toml unless the operator
// turned it on — via the providers-dashboard toggle or the env var.
return isFeatureFlagEnabled("OMNIROUTE_AUTO_SYNC_CODEX_PROFILES");
}
function forwardAuthHeaders(request: Request): Record<string, string> {
const headers: Record<string, string> = {};
const cookie = request.headers.get("cookie");
const authorization = request.headers.get("authorization");
if (cookie) headers.cookie = cookie;
if (authorization) headers.authorization = authorization;
return headers;
}
export async function autoSyncCodexProfilesFromLiveCatalog(
request: Request,
reason: string
): Promise<SyncResult> {
if (!isAutoSyncEnabled()) {
return { ok: false, skipped: true, reason: "disabled" };
}
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return { ok: false, skipped: true, reason: writeGuard };
}
const baseUrl = getModelSyncInternalBaseUrl().replace(/\/$/, "");
const res = await fetch(`${baseUrl}/v1/models`, {
headers: forwardAuthHeaders(request),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
return { ok: false, skipped: true, reason: `catalog_http_${res.status}` };
}
const body = await res.json();
const candidateModels = Array.isArray(body) ? body : body.data || body.models || [];
const models = Array.isArray(candidateModels) ? candidateModels : [];
const codexPaths = getCliConfigPaths("codex");
if (!codexPaths?.config) {
return { ok: false, skipped: true, reason: "codex_config_path_unavailable" };
}
const codexHome = path.dirname(codexPaths.config);
// Reuse the CLI generator so automatic sync and `omniroute setup-codex`
// stay behaviorally identical.
// @ts-ignore - bin CLI modules are shipped as ESM JavaScript, without TS declarations.
const { syncCodexProfilesFromModels } = await import("../../../bin/cli/commands/setup-codex.mjs");
const result = await syncCodexProfilesFromModels(models, { codexHome });
return {
ok: true,
written: result.written,
skipped: result.skipped,
reason,
};
}
@@ -0,0 +1,25 @@
import path from "node:path";
import os from "node:os";
const CONFIG_PATH = path.join(os.homedir(), ".claude", "settings.json");
export function generateClaudeConfig(options: {
baseUrl: string;
apiKey: string;
model?: string;
}): string {
let base = options.baseUrl;
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const model = options.model || "claude-3-5-sonnet-20241022";
const config = {
baseUrl: `${base}/v1`,
authToken: options.apiKey,
models: [{ id: model }],
};
return JSON.stringify(config, null, 2);
}
@@ -0,0 +1,23 @@
import path from "node:path";
import os from "node:os";
const CONFIG_PATH = path.join(os.homedir(), ".cline", "data", "globalState.json");
export function generateClineConfig(options: {
baseUrl: string;
apiKey: string;
model?: string;
}): string {
let base = options.baseUrl;
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {
openAiBaseUrl: `${base}/v1`,
openAiApiKey: options.apiKey,
};
return JSON.stringify(config, null, 2);
}
@@ -0,0 +1,34 @@
import path from "node:path";
import os from "node:os";
let yaml: typeof import("js-yaml") | null = null;
async function loadYaml() {
if (!yaml) {
yaml = await import("js-yaml");
}
return yaml;
}
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.yaml");
export async function generateCodexConfig(options: {
baseUrl: string;
apiKey: string;
model?: string;
}): Promise<string> {
const y = await loadYaml();
let base = options.baseUrl;
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {
openai: {
api_key: options.apiKey,
base_url: `${base}/v1`,
},
};
return y.dump(config, { lineWidth: -1 });
}
@@ -0,0 +1,37 @@
import path from "node:path";
import os from "node:os";
let yaml: typeof import("js-yaml") | null = null;
async function loadYaml() {
if (!yaml) {
yaml = await import("js-yaml");
}
return yaml;
}
const CONFIG_PATH = path.join(os.homedir(), ".continue", "config.yaml");
export async function generateContinueConfig(options: {
baseUrl: string;
apiKey: string;
model?: string;
}): Promise<string> {
const y = await loadYaml();
let base = options.baseUrl;
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {
models: [
{
title: "OmniRoute",
apiKey: options.apiKey,
apiBase: `${base}/v1`,
},
],
};
return y.dump(config, { lineWidth: -1 });
}
@@ -0,0 +1,210 @@
/**
* Hermes Agent — Rich multi-role config generator & saver
*
* This is the implementation for the advanced "Hermes Agent" (Nous Research terminal agent).
* It is treated completely separately from the original simple "Hermes" guide tool.
*
* Hermes Agent supports many independent model slots:
* - default (main conversation)
* - delegation (sub-agent orchestrator)
* - auxiliary.* (vision, compression, web_extract, skills_hub, approval, ...)
*
* The UI (HermesAgentToolCard) will offer a dropdown for EACH of these roles.
*
* Data Model (what the frontend sends and this module consumes):
*
* interface HermesAgentRoleSelection {
* role: 'default' | 'delegation' | 'vision' | 'compression' | 'web_extract' | 'skills_hub' | 'approval' | ...;
* model: string; // the model name the user chose from OmniRoute
* }
*
* interface HermesAgentConfigPayload {
* baseUrl: string; // usually the OmniRoute base URL
* keyId?: string | null; // preferred: reference to a stored key
* apiKey?: string | null; // fallback plaintext key
* selections: HermesAgentRoleSelection[];
* }
*/
import * as yaml from "js-yaml";
import { getHermesConfigPath } from "./hermesHome.ts";
export const HERMES_AGENT_ROLES = [
{ id: "default", label: "Default (main)", description: "Primary conversation model" },
{
id: "delegation",
label: "Delegation (subagents)",
description: "Orchestrator and sub-agent spawning model",
},
{ id: "vision", label: "Vision", description: "Image and screenshot understanding" },
{ id: "compression", label: "Compression", description: "Prompt compression and summarization" },
{ id: "web_extract", label: "Web Extract", description: "Web page / content extraction" },
{ id: "skills_hub", label: "Skills Hub", description: "Skills and tool-use reasoning" },
{ id: "approval", label: "Approval", description: "Safety and approval decisions" },
] as const;
export type HermesAgentRole = (typeof HERMES_AGENT_ROLES)[number]["id"];
export interface HermesAgentRoleSelection {
role: HermesAgentRole;
model: string;
}
export interface HermesAgentConfigPayload {
baseUrl: string;
keyId?: string | null;
apiKey?: string | null;
selections: HermesAgentRoleSelection[];
}
// Resolved lazily at call-time so HERMES_HOME is always honoured (#3628).
const getConfigPath = () => getHermesConfigPath();
// Build a normalized base URL for Hermes (no trailing slash, no /v1 suffix on the provider entry)
function normalizeBaseUrl(base: string): string {
let b = base.trim();
while (b.endsWith("/")) b = b.slice(0, -1);
if (b.endsWith("/v1")) b = b.slice(0, -3);
return b;
}
function getProviderBlock(baseUrl: string, apiKey: string) {
const normalized = normalizeBaseUrl(baseUrl);
return {
provider: "omniroute",
model: "", // will be filled per-role
base_url: `${normalized}/v1`,
api_key: apiKey,
};
}
/**
* Generate the full merged YAML for Hermes Agent config.
* This is the core of the rich implementation.
*/
export async function generateHermesAgentConfig(
payload: HermesAgentConfigPayload
): Promise<{ yaml: string; error?: string }> {
const { baseUrl, keyId, apiKey, selections } = payload;
if (!baseUrl) {
return { yaml: "", error: "baseUrl is required" };
}
// Resolve the actual key to use (in real impl we would look up keyId)
const resolvedKey = apiKey || "YOUR_OMNIROUTE_API_KEY_HERE";
// Read existing config if present (non-destructive merge)
let existing: any = {};
try {
const fs = await import("node:fs/promises");
const raw = await fs.readFile(getConfigPath(), "utf-8");
existing = yaml.load(raw) || {};
} catch {
// no existing file — start fresh
}
// Build the providers.omniroute entry (shared)
const normalizedBase = normalizeBaseUrl(baseUrl);
const omnirouteProvider = {
base_url: `${normalizedBase}/v1`,
api_key: resolvedKey,
};
// Start from existing or empty
const next: any = {
...existing,
providers: {
...(existing.providers || {}),
omniroute: omnirouteProvider,
},
};
// Apply each role selection
for (const sel of selections) {
const { role, model } = sel;
if (role === "default") {
next.model = {
...(existing.model || {}),
default: model,
provider: "omniroute",
base_url: `${normalizedBase}/v1`,
};
} else if (role === "delegation") {
next.delegation = {
...(existing.delegation || {}),
model,
provider: "omniroute",
base_url: `${normalizedBase}/v1`,
api_key: resolvedKey,
};
} else {
// auxiliary.* roles
if (!next.auxiliary) next.auxiliary = {};
next.auxiliary[role] = {
...(existing.auxiliary?.[role] || {}),
provider: "omniroute",
model,
base_url: `${normalizedBase}/v1`,
api_key: resolvedKey,
};
}
}
const outputYaml = yaml.dump(next, { lineWidth: -1, noRefs: true });
return { yaml: outputYaml };
}
/**
* Read the current Hermes Agent configuration and extract the model
* for each supported role.
*/
export async function getCurrentHermesAgentRoles(): Promise<
Record<string, { model: string; provider?: string; base_url?: string }>
> {
const fs = await import("node:fs/promises");
let config: any = {};
try {
const raw = await fs.readFile(getConfigPath(), "utf-8");
config = yaml.load(raw) || {};
} catch {
return {};
}
const result: Record<string, any> = {};
// default
if (config.model?.default) {
result.default = {
model: config.model.default,
provider: config.model.provider,
base_url: config.model.base_url,
};
}
// delegation
if (config.delegation?.model) {
result.delegation = {
model: config.delegation.model,
provider: config.delegation.provider,
base_url: config.delegation.base_url,
};
}
// auxiliary roles
if (config.auxiliary && typeof config.auxiliary === "object") {
for (const [role, val] of Object.entries(config.auxiliary)) {
if (val && typeof val === "object" && (val as any).model) {
result[role] = {
model: (val as any).model,
provider: (val as any).provider,
base_url: (val as any).base_url,
};
}
}
}
return result;
}
@@ -0,0 +1,47 @@
import path from "node:path";
import os from "node:os";
let yaml: typeof import("js-yaml") | null = null;
async function loadYaml() {
if (!yaml) {
yaml = await import("js-yaml");
}
return yaml;
}
const CONFIG_PATH = path.join(os.homedir(), ".hermes", "config.yaml");
export async function generateHermesConfig(options: {
baseUrl: string;
apiKey: string;
model?: string;
}): Promise<string> {
const y = await loadYaml();
let base = options.baseUrl;
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
if (!options.model) {
throw new Error("model is required");
}
const model = options.model;
const config = {
model: {
default: model,
provider: "omniroute",
base_url: `${base}/v1`,
},
providers: {
omniroute: {
base_url: `${base}/v1`,
api_key: options.apiKey,
},
},
};
return y.dump(config, { lineWidth: -1 });
}
@@ -0,0 +1,40 @@
/**
* Shared Hermes home-directory resolver (#3628).
*
* The Hermes PowerShell installer (Windows) writes its default config under
* `%LOCALAPPDATA%\hermes`, exposed as the `HERMES_HOME` env var. OmniRoute
* previously hard-coded `~/.hermes` everywhere, so the two config files never
* met on Windows.
*
* IMPORTANT: the env var MUST be read at call-time (i.e. inside a function),
* never at module-load time. `TOOL_CONFIG_PATHS` in index.ts is evaluated
* eagerly — any constant defined there would freeze the path at import.
* That's why this module exports plain functions instead of constants.
*/
import path from "node:path";
import os from "node:os";
/**
* Returns the Hermes home directory.
*
* Resolution order:
* 1. `HERMES_HOME` env var (if non-empty)
* 2. `~/.hermes` (cross-platform fallback)
*
* Reads `process.env.HERMES_HOME` every call so tests can set/unset the var
* without module-cache interference.
*/
export function getHermesHome(): string {
const override = (process.env.HERMES_HOME || "").trim();
return override || path.join(os.homedir(), ".hermes");
}
/**
* Returns the canonical path to the Hermes Agent `config.yaml`.
* Always delegates to `getHermesHome()` so it picks up `HERMES_HOME` at
* call-time.
*/
export function getHermesConfigPath(): string {
return path.join(getHermesHome(), "config.yaml");
}
@@ -0,0 +1,124 @@
import path from "node:path";
import os from "node:os";
import { getHermesConfigPath } from "./hermesHome.ts";
import { generateClaudeConfig } from "./claude";
import { generateClineConfig } from "./cline";
import { generateCodexConfig } from "./codex";
import { generateContinueConfig } from "./continue";
import { generateHermesConfig } from "./hermes";
import { generateHermesAgentConfig, type HermesAgentConfigPayload } from "./hermes-agent";
import { generateKilocodeConfig } from "./kilocode";
import { generateOpencodeConfig } from "./opencode";
export interface GenerateOptions {
baseUrl: string;
apiKey: string;
model?: string;
}
export interface GenerateResult {
success: boolean;
configPath: string;
content?: string;
error?: string;
}
export function validateBaseUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
function expandHome(p: string): string {
const home = os.homedir();
return p.replace(/^~\//, home + "/");
}
// Static paths that do not depend on runtime env vars can stay eagerly computed.
const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = {
claude: path.join(os.homedir(), ".claude", "settings.json"),
codex: path.join(os.homedir(), ".codex", "config.yaml"),
opencode: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
continue: path.join(os.homedir(), ".continue", "config.yaml"),
};
/**
* Returns the config path for a given tool.
*
* Hermes entries are resolved lazily at call-time so `HERMES_HOME` is always
* honoured (#3628). All other tools use the eagerly-computed static map.
*/
function getToolConfigPath(toolId: string): string {
if (toolId === "hermes" || toolId === "hermes-agent") {
return getHermesConfigPath();
}
return STATIC_TOOL_CONFIG_PATHS[toolId] ?? "";
}
type ConfigGenerator = (options: GenerateOptions) => string | Promise<string>;
const GENERATORS: Record<string, ConfigGenerator> = {
claude: generateClaudeConfig,
codex: generateCodexConfig,
opencode: generateOpencodeConfig,
cline: generateClineConfig,
kilocode: generateKilocodeConfig,
continue: generateContinueConfig,
hermes: generateHermesConfig,
"hermes-agent": generateHermesAgentConfig as any, // rich multi-role version
};
export async function generateConfig(
toolId: string,
options: GenerateOptions
): Promise<GenerateResult> {
if (!validateBaseUrl(options.baseUrl)) {
return {
success: false,
configPath: "",
error: "Invalid baseUrl: must be an absolute HTTP(S) URL",
};
}
if (!options.apiKey || options.apiKey.trim().length === 0) {
return { success: false, configPath: "", error: "API key is required" };
}
try {
const generate = GENERATORS[toolId];
if (!generate) {
return { success: false, configPath: "", error: `Unknown tool: ${toolId}` };
}
const content = await generate(options);
const configPath = getToolConfigPath(toolId);
return { success: true, configPath, content };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, configPath: "", error: `Generation failed: ${msg}` };
}
}
export async function generateAllConfigs(options: GenerateOptions): Promise<GenerateResult[]> {
const toolIds = [
"claude",
"codex",
"opencode",
"cline",
"kilocode",
"continue",
"hermes",
] as const;
const results = await Promise.allSettled(toolIds.map((id) => generateConfig(id, options)));
return results.map((r) =>
r.status === "fulfilled"
? r.value
: { success: false, configPath: "", error: r.reason?.message || "Unknown error" }
);
}
@@ -0,0 +1,23 @@
import path from "node:path";
import os from "node:os";
const CONFIG_PATH = path.join(os.homedir(), ".config", "kilocode", "settings.json");
export function generateKilocodeConfig(options: {
baseUrl: string;
apiKey: string;
model?: string;
}): string {
let base = options.baseUrl;
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {
apiKey: options.apiKey,
baseUrl: `${base}/v1`,
};
return JSON.stringify(config, null, 2);
}
@@ -0,0 +1,443 @@
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import {
parseOutboundUrl,
isCloudMetadataHost,
OutboundUrlGuardError,
} from "../../../shared/network/outboundUrlGuard";
const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json");
/**
* SSRF guard for the catalog fetch (CodeQL js/request-forgery #326). The catalog
* source is the user's OWN OmniRoute instance, so loopback/private hosts are the
* legitimate default and must stay allowed — we cannot use the public-only guard
* here. What has NO legitimate use as a catalog source is the cloud-metadata /
* link-local pivot (169.254.169.254, metadata.google.internal, …): that is the
* classic SSRF→IAM-credential escalation and is blocked unconditionally, along
* with non-http(s) protocols and embedded credentials (via parseOutboundUrl).
*/
export function assertSafeCatalogUrl(rawUrl: string): URL {
const url = parseOutboundUrl(rawUrl); // throws on bad protocol / embedded creds
if (isCloudMetadataHost(url.hostname)) {
throw new OutboundUrlGuardError(
"Blocked cloud-metadata catalog URL (SSRF protection)",
{ code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname }
);
}
// Return the re-parsed URL so callers fetch the validated value (a `new URL()`
// round-trip is a recognized request-forgery barrier — clears CodeQL #326).
return url;
}
/**
* OpenAI-compatible model entry — subset of fields the /v1/models endpoint
* returns. Only the fields we need to emit `limit.context` / `limit.output`
* are typed.
*/
interface CatalogModelEntry {
id: string;
owned_by?: string;
/** OpenAI-compatible field name; some upstreams return this. */
context_length?: number;
max_context_window_tokens?: number;
/** Optional max output tokens; used to populate `limit.output`. */
max_output_tokens?: number;
max_input_tokens?: number;
/** Optional structured capability flags. */
capabilities?: {
attachment?: boolean;
reasoning?: boolean;
temperature?: boolean;
tool_calling?: boolean;
vision?: boolean;
};
}
/** Per-model override carried over from the user's existing opencode.json. */
interface ExistingModelEntry {
name?: string;
attachment?: boolean;
reasoning?: boolean;
temperature?: boolean;
tool_call?: boolean;
limit?: { context?: number; input?: number; output?: number };
// Allow arbitrary other keys to round-trip through untouched.
[key: string]: unknown;
}
interface ExistingProviderEntry {
name?: string;
npm?: string;
options?: Record<string, unknown>;
models?: Record<string, ExistingModelEntry>;
[key: string]: unknown;
}
interface ExistingConfig {
$schema?: string;
provider?: Record<string, ExistingProviderEntry>;
model?: string;
small_model?: string;
[key: string]: unknown;
}
export interface CatalogFetchResult {
/** Models keyed by id, as returned by /v1/models. */
byId: Map<string, CatalogModelEntry>;
/** Provider ids that had at least one model in the catalog. */
providerIds: Set<string>;
/** Models that have a usable `context_length` (positive finite number). */
modelsWithContext: number;
/** Total models returned by the catalog. */
total: number;
}
/**
* Fetch the live `/v1/models` catalog from OmniRoute. The catalog is the
* single source of truth for context windows — opencode.json must NOT
* hardcode values, otherwise we drift from the provider's actual limits.
*/
export async function fetchOmniRouteCatalog(
baseUrl: string,
apiKey: string,
timeoutMs = 5_000
): Promise<CatalogFetchResult> {
const cleanBase = baseUrl.replace(/\/+$/, "");
const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`;
const result: CatalogFetchResult = {
byId: new Map(),
providerIds: new Set(),
modelsWithContext: 0,
total: 0,
};
// SSRF guard (CodeQL #326): baseUrl is user-controlled — block the cloud-metadata
// pivot before issuing the request. Loopback stays allowed. Fetch the VALIDATED,
// re-parsed URL the guard returns (not the raw string) so the taint is severed.
const safeUrl = assertSafeCatalogUrl(`${baseURL}/models`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(safeUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(
`OmniRoute /v1/models returned ${response.status} ${response.statusText}`
);
}
const body = (await response.json()) as unknown;
const list: unknown[] = Array.isArray(body)
? body
: body && typeof body === "object" && Array.isArray((body as { data?: unknown[] }).data)
? ((body as { data: unknown[] }).data as unknown[])
: [];
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const r = raw as CatalogModelEntry;
if (typeof r.id !== "string" || !r.id.trim()) continue;
const id = r.id.trim();
result.byId.set(id, r);
result.total += 1;
if (typeof r.owned_by === "string" && r.owned_by.length > 0) {
result.providerIds.add(r.owned_by);
}
const candidates = [r.context_length, r.max_context_window_tokens];
if (candidates.some((c) => typeof c === "number" && Number.isFinite(c) && c > 0)) {
result.modelsWithContext += 1;
}
}
} finally {
clearTimeout(timer);
}
return result;
}
/**
* Resolve the context length for a single catalog entry.
* Prefers `context_length` (OpenAI-compatible) over `max_context_window_tokens`
* (llama.cpp-style). Returns `undefined` when neither is a positive integer —
* this is intentional: we MUST NOT invent a default, because combos whose
* targets' contexts are unknown to the catalog will mis-report a context
* window. The user can override per-model via `limit.context` in their
* existing opencode.json, or fix the upstream catalog.
*/
function resolveContextLength(entry: CatalogModelEntry): number | undefined {
const candidates = [entry.context_length, entry.max_context_window_tokens];
for (const c of candidates) {
if (typeof c === "number" && Number.isFinite(c) && c > 0) return c;
}
return undefined;
}
/**
* Build the entry that ends up under `provider.<name>.models[id]` in the
* emitted opencode.json. Precedence:
*
* 1. Existing manual override in the user's opencode.json (`limit.context`).
* 2. Catalog `context_length` / `max_context_window_tokens`.
*
* If neither is available, the entry is returned WITHOUT a `limit` block so
* the caller can decide whether to skip the model entirely or surface a
* warning. We never fabricate a default context window.
*/
function buildModelEntry(
id: string,
catalog: CatalogModelEntry | undefined,
existing: ExistingModelEntry | undefined
): ExistingModelEntry {
// Carry over user-set "name" first; fall back to id when absent.
const name = (typeof existing?.name === "string" && existing.name.trim()) || id;
const entry: ExistingModelEntry = { name };
// Round-trip capability flags from the existing config (if any).
for (const flag of ["attachment", "reasoning", "temperature", "tool_call"] as const) {
const value = existing?.[flag];
if (typeof value === "boolean") entry[flag] = value;
}
// Preserve any extra top-level keys the user set (variants, headers, etc.)
// that we don't model explicitly.
if (existing) {
for (const [k, v] of Object.entries(existing)) {
if (k === "name" || k === "limit") continue;
if (v === undefined) continue;
if (!(k in entry)) entry[k] = v;
}
}
// Resolve the context window. Honor an explicit user override, then fall
// back to the catalog. We do NOT synthesize a default — if the catalog
// is unaware of a model's window, the opencode.json will simply omit
// `limit.context` for that model and OpenCode's own heuristics apply.
// (OpenCode v1 defaults to 128K when `limit.context` is missing.)
const userLimit = existing?.limit?.context;
const catalogLimit = catalog ? resolveContextLength(catalog) : undefined;
const context =
typeof userLimit === "number" && userLimit > 0
? userLimit
: catalogLimit;
// `limit.output` is REQUIRED by OpenCode's v1 provider schema (configV1).
// Use the catalog's max_output_tokens when available; otherwise fall
// back to the user's existing `limit.output` and finally to a small
// default (8K) so OpenCode never errors on a totally missing output cap.
// We do NOT default context — context is a property of the model and
// we have no business guessing. Output is a per-request setting and a
// small default is harmless when truly unknown.
const userOutput = existing?.limit?.output;
const catalogOutput =
catalog && typeof catalog.max_output_tokens === "number" && catalog.max_output_tokens > 0
? catalog.max_output_tokens
: undefined;
const output =
typeof userOutput === "number" && userOutput > 0
? userOutput
: catalogOutput ?? 8_192;
// Emit `limit` only if we have at least one of context/output. We never
// emit a half-baked limit block with only an `output` (would be misleading).
if (typeof context === "number" || typeof userOutput === "number" || typeof catalogOutput === "number") {
const limit: { context?: number; input?: number; output?: number } = {};
if (typeof context === "number") limit.context = context;
if (typeof userOutput === "number" || typeof catalogOutput === "number") {
limit.output =
typeof userOutput === "number" && userOutput > 0
? userOutput
: catalogOutput ?? 8_192;
}
const userInput = existing?.limit?.input;
if (typeof userInput === "number" && userInput > 0) {
limit.input = userInput;
} else if (catalog) {
const maxInput = catalog.max_input_tokens;
if (typeof maxInput === "number" && maxInput > 0) limit.input = maxInput;
}
entry.limit = limit;
}
return entry;
}
/**
* Load the user's current opencode.json (if any) so we can preserve names,
* capability flags, and explicit `limit.context` overrides. JSONC comments
* are not supported — we parse as plain JSON. If parsing fails, we fall
* back to an empty config; the resulting write will lose comments, but
* that matches the existing CLI behavior of `config set opencode`.
*/
function loadExistingConfig(): ExistingConfig {
try {
if (!fs.existsSync(CONFIG_PATH)) return {};
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
return JSON.parse(raw) as ExistingConfig;
} catch {
return {};
}
}
export interface GenerateOpencodeOptions {
baseUrl: string;
apiKey: string;
model?: string;
/**
* Override the default `provider.id` used in the generated config.
* Defaults to `"omniroute"`.
*/
providerId?: string;
/**
* If `true` (default), the generator fetches the live `/v1/models` catalog
* so every model entry has an explicit `limit.context`. The catalog is the
* single source of truth for context windows; we never invent defaults.
*
* When the catalog request fails, the generator throws — opencode.json must
* not be emitted with stale or fabricated values. The CLI can catch the
* error and decide whether to surface it to the user.
*/
fetchCatalog?: boolean;
/**
* Request timeout for the catalog fetch, in milliseconds. Defaults to 5s.
*/
catalogTimeoutMs?: number;
}
/**
* Generate a full `opencode.json` document for OmniRoute. The catalog is the
* single source of truth for context windows — we never hardcode values.
*
* Behavior:
* - Preserves the user's existing provider name, npm, options, and
* per-model names / capability flags.
* - For each existing model id, the catalog's `context_length` wins
* unless the user already set an explicit `limit.context` in the file.
* - For each catalog model id the user did NOT have, a new entry is
* added with `limit.context` populated when the catalog has it.
* - If the catalog has no context for a model AND the user has no
* override, the model is emitted WITHOUT a `limit.context` field.
* OpenCode's own heuristic (typically 128K) applies.
* - Throws if the catalog fetch fails — the user must fix the upstream
* before we can generate a reliable opencode.json.
*/
export async function generateOpencodeConfig(
options: GenerateOpencodeOptions
): Promise<string> {
const cleanBase = options.baseUrl.replace(/\/+$/, "");
const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`;
const providerId = options.providerId?.trim() || "omniroute";
const fetchCatalog = options.fetchCatalog !== false;
const timeoutMs = options.catalogTimeoutMs ?? 5_000;
// Fetch live catalog. The catalog is the source of truth — if it fails,
// we refuse to write an opencode.json that could mislead OpenCode into
// picking the wrong context window.
let catalogById = new Map<string, CatalogModelEntry>();
if (fetchCatalog) {
const result = await fetchOmniRouteCatalog(baseURL, options.apiKey, timeoutMs);
catalogById = result.byId;
} else {
throw new Error(
"fetchCatalog=false is not supported. The catalog is the single source " +
"of truth for context windows — without it, opencode.json would carry " +
"fabricated or stale values."
);
}
// Load existing config so we preserve names, capability flags, and any
// explicit `limit.context` overrides the user has set.
const existing = loadExistingConfig();
const existingProvider = existing.provider?.[providerId];
const existingModels = (existingProvider?.models ?? {}) as Record<string, ExistingModelEntry>;
// Build the merged model map: catalog first, then existing (so existing
// values can win for matching ids).
const mergedIds = new Set<string>([...catalogById.keys(), ...Object.keys(existingModels)]);
const mergedModels: Record<string, ExistingModelEntry> = {};
for (const id of mergedIds) {
mergedModels[id] = buildModelEntry(id, catalogById.get(id), existingModels[id]);
}
const provider: Record<string, unknown> = {
name: existingProvider?.name ?? "OmniRoute",
npm: existingProvider?.npm ?? "@ai-sdk/openai-compatible",
options: {
baseURL,
apiKey: options.apiKey,
...(existingProvider?.options ?? {}),
},
models: mergedModels,
};
// Carry over any other provider-level keys the user set (e.g. headers).
if (existingProvider) {
for (const [k, v] of Object.entries(existingProvider)) {
if (k === "name" || k === "npm" || k === "options" || k === "models") continue;
provider[k] = v;
}
}
const config: Record<string, unknown> = {
$schema: existing.$schema ?? "https://opencode.ai/config.json",
provider: { ...(existing.provider ?? {}), [providerId]: provider },
};
// Carry over top-level keys the user may have set (compaction, plugins,
// permission, mcp, etc.). We intentionally do NOT preserve `model` /
// `small_model` unless the generator was given an explicit model — the
// user's top-level model selection may point at a model that no longer
// exists, so we require an explicit value via `options.model`.
for (const [k, v] of Object.entries(existing)) {
if (k === "$schema" || k === "provider" || k === "model" || k === "small_model") continue;
config[k] = v;
}
if (typeof options.model === "string" && options.model.trim()) {
config.model = `${providerId}/${options.model.trim()}`;
} else if (typeof existing.model === "string" && existing.model.trim()) {
// Preserve the user's previous top-level `model` so a re-run doesn't
// silently drop their selection.
config.model = existing.model;
}
if (typeof existing.small_model === "string" && existing.small_model.trim()) {
config.small_model = existing.small_model;
}
return JSON.stringify(config, null, 2);
}
/**
* Synchronous variant used by the legacy CLI path. Emits a minimal
* `opencode.json` (just provider options + top-level model) without a
* catalog fetch. Kept for back-compat with the previous `config set
* opencode` command; the async variant above is what callers should use
* for the full, context-window-aware flow.
*/
export function generateOpencodeConfigSync(options: {
baseUrl: string;
apiKey: string;
model?: string;
}): string {
const cleanBase = options.baseUrl.replace(/\/+$/, "");
const base = cleanBase.endsWith("/v1") ? cleanBase.slice(0, -3) : cleanBase;
const config = {
provider: "omniroute",
baseURL: `${base}/v1`,
apiKey: options.apiKey,
model: options.model || "opencode",
};
return JSON.stringify(config, null, 2);
}
// Backwards-compatible default export: keeps the existing call sites in
// `config.mjs` working. The async variant above is the preferred entry
// point for new callers.
export default generateOpencodeConfigSync;
+41
View File
@@ -0,0 +1,41 @@
import path from "node:path";
import os from "node:os";
export interface DoctorCheckResult {
name: string;
status: "ok" | "warn" | "fail";
message: string;
details: Record<string, unknown>;
}
export async function collectCliToolChecks(): Promise<DoctorCheckResult[]> {
const { detectAllTools } = await import("../tool-detector.ts");
const tools = await detectAllTools();
return tools.map((tool) => {
if (!tool.installed) {
return {
name: `CLI: ${tool.name}`,
status: "warn" as const,
message: `${tool.name} not installed`,
details: { id: tool.id, installed: false },
};
}
if (!tool.configured) {
return {
name: `CLI: ${tool.name}`,
status: "warn" as const,
message: `${tool.name} not configured for OmniRoute`,
details: { id: tool.id, configured: false },
};
}
return {
name: `CLI: ${tool.name}`,
status: "ok" as const,
message: `${tool.name} configured`,
details: { id: tool.id, configured: true },
};
});
}
+78
View File
@@ -0,0 +1,78 @@
export interface LogStreamOptions {
baseUrl?: string;
filters?: string[];
follow?: boolean;
timeout?: number;
headers?: HeadersInit;
}
export interface LogStream {
stream: ReadableStream<Uint8Array>;
stop: () => void;
}
export function createLogStream(options: LogStreamOptions = {}): LogStream {
const baseUrl = options.baseUrl || "http://localhost:20128";
const filters = options.filters || [];
const follow = options.follow ?? false;
const timeout = options.timeout || 30000;
const headers = options.headers;
const controller = new AbortController();
const { signal } = controller;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let url = `${baseUrl}/api/cli-tools/logs?follow=${follow}`;
if (filters.length > 0) {
url += `&filter=${encodeURIComponent(filters.join(","))}`;
}
const timeoutId = setTimeout(() => {
if (follow) return; // Don't timeout follow mode
controller.error(new Error(`Log stream timed out after ${timeout}ms`));
}, timeout);
try {
const response = await fetch(url, { signal, headers });
if (!response.ok) {
controller.error(new Error(`HTTP ${response.status}: ${response.statusText}`));
clearTimeout(timeoutId);
return;
}
if (!response.body) {
controller.error(new Error("Response body is null"));
clearTimeout(timeoutId);
return;
}
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (signal.aborted) break;
controller.enqueue(value);
}
controller.close();
clearTimeout(timeoutId);
} catch (err) {
if (signal.aborted) return; // Expected stop
controller.error(err instanceof Error ? err : new Error(String(err)));
clearTimeout(timeoutId);
}
},
cancel() {
controller.abort();
},
});
return {
stream,
stop: () => controller.abort(),
};
}
+165
View File
@@ -0,0 +1,165 @@
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { getCurrentHermesAgentRoles } from "./config-generator/hermes-agent";
import { getCachedLoginShellPath, mergeShellPath } from "../../shared/services/loginShellPath";
const execFileAsync = promisify(execFile);
let execFileImpl = execFileAsync;
// #3321: macOS GUI/Electron truncates PATH, so `which`/`--version` probes miss Homebrew/
// nvm/volta CLIs and the doctor reports them "not installed". Build a lookup env enriched
// with the login-shell PATH (darwin-only, cached, fail-safe → returns process.env elsewhere).
function detectorEnv(): NodeJS.ProcessEnv {
const loginShellPath = getCachedLoginShellPath();
if (!loginShellPath) return process.env;
return { ...process.env, PATH: mergeShellPath(process.env.PATH || "", loginShellPath) };
}
export function __setExecFileImpl(fn: typeof execFileAsync): void {
execFileImpl = fn;
}
export interface DetectedTool {
id: string;
name: string;
installed: boolean;
version?: string;
configPath: string;
configured: boolean;
configContents?: string;
// Rich per-role status for Hermes Agent
hermesAgentRoles?: Record<
string,
{
model: string;
provider?: string;
usingOmniRoute: boolean;
}
>;
}
const TOOLS = [
{ id: "claude", name: "Claude Code", configPath: "~/.claude/settings.json" },
{ id: "codex", name: "Codex CLI", configPath: "~/.codex/config.yaml" },
{ id: "opencode", name: "OpenCode", configPath: "~/.config/opencode/opencode.json" },
{ id: "cline", name: "Cline", configPath: "~/.cline/data/globalState.json" },
{ id: "kilocode", name: "Kilo Code", configPath: "~/.config/kilocode/settings.json" },
{ id: "continue", name: "Continue", configPath: "~/.continue/config.yaml" },
{ id: "hermes", name: "Hermes", configPath: "~/.hermes/config.yaml" },
{ id: "hermes-agent", name: "Hermes Agent", configPath: "~/.hermes/config.yaml" },
{ id: "openclaw", name: "OpenClaw", configPath: "~/.openclaw/openclaw.json" },
] as const;
const BINARY_NAMES: Record<string, string> = {
claude: "claude",
codex: "codex",
opencode: "opencode",
cline: "cline",
kilocode: "kilocode",
continue: "continue",
hermes: "hermes",
"hermes-agent": "hermes",
openclaw: "openclaw",
};
function expandHome(p: string): string {
const home = os.homedir();
return p.replace(/^~\//, home + "/");
}
function isConfigured(content: string, baseUrl: string): boolean {
const normalized = baseUrl.replace(/\/+$/, "");
return (
content.includes(normalized) ||
content.includes("localhost:20128") ||
content.includes("OMNIROUTE_BASE_URL")
);
}
async function detectBinary(name: string): Promise<{ installed: boolean; version?: string }> {
const binary = BINARY_NAMES[name] || name;
const env = detectorEnv();
try {
const { stdout } = await execFileImpl(binary, ["--version"], { timeout: 5000, env });
const version = stdout.trim().replace(/^v/, "");
return { installed: true, version };
} catch {
try {
// Try `which` as fallback
const { stdout } = await execFileAsync("which", [binary], { timeout: 5000, env });
if (stdout.trim()) {
return { installed: true };
}
} catch {}
return { installed: false };
}
}
async function readConfigFile(configPath: string): Promise<string | null> {
try {
const { readFileSync } = await import("node:fs");
const expanded = expandHome(configPath);
if (!expanded) return null;
return readFileSync(expanded, "utf-8");
} catch {
return null;
}
}
export async function detectTool(id: string): Promise<DetectedTool | null> {
const tool = TOOLS.find((t) => t.id === id);
if (!tool) return null;
const { installed, version } = await detectBinary(tool.id);
const configPath = expandHome(tool.configPath);
const configContents = await readConfigFile(tool.configPath);
const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
const result: DetectedTool = {
id: tool.id,
name: tool.name,
installed,
version,
configPath,
configured,
configContents: configContents ?? undefined,
};
// Rich per-role status only for Hermes Agent
if (tool.id === "hermes-agent") {
try {
const roles = await getCurrentHermesAgentRoles();
const richRoles: Record<string, any> = {};
Object.entries(roles).forEach(([role, info]) => {
const usingOmni =
info?.provider === "omniroute" ||
(info?.base_url || "").includes("20128") ||
(info?.base_url || "").includes("localhost:20128");
richRoles[role] = {
model: info.model,
provider: info.provider,
usingOmniRoute: usingOmni,
};
});
result.hermesAgentRoles = richRoles;
} catch {
// ignore rich status is optional
}
}
return result;
}
export async function detectAllTools(): Promise<DetectedTool[]> {
const results = await Promise.allSettled(TOOLS.map((t) => detectTool(t.id)));
return results
.filter((r) => r.status === "fulfilled" && r.value !== null)
.map((r) => (r as PromiseFulfilledResult<DetectedTool>).value);
}
+47
View File
@@ -0,0 +1,47 @@
// DRY: shared between /api/cli-tools/status and /api/cli-tools/all-statuses (plan 14 F2)
// In-memory mtime-based cache for batch CLI tool status results.
// Cache invalidated when mtime changes. Lives until server restart (no TTL).
import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus";
export interface CacheEntry {
mtimeMs: number;
result: ToolBatchStatus;
}
/** Singleton in-memory cache: toolId → { mtimeMs, result } */
const _cache = new Map<string, CacheEntry>();
/**
* Get cached result for a toolId if mtime matches.
* Returns null if:
* - entry doesn't exist
* - stored mtimeMs !== provided mtimeMs (config file changed)
*/
export function getCached(toolId: string, mtimeMs: number): ToolBatchStatus | null {
const entry = _cache.get(toolId);
if (!entry) return null;
if (entry.mtimeMs !== mtimeMs) return null;
return entry.result;
}
/**
* Store a result in the cache for a toolId with its mtime.
*/
export function setCached(toolId: string, mtimeMs: number, result: ToolBatchStatus): void {
_cache.set(toolId, { mtimeMs, result });
}
/**
* Remove a specific toolId from the cache (e.g. after config write).
*/
export function invalidate(toolId: string): void {
_cache.delete(toolId);
}
/**
* Clear all cached entries. Primarily for testing isolation.
*/
export function clearCache(): void {
_cache.clear();
}
+112
View File
@@ -0,0 +1,112 @@
// DRY: shared between /api/cli-tools/status and /api/cli-tools/all-statuses (plan 14 F2)
import fs from "fs/promises";
import { getCliPrimaryConfigPath } from "@/shared/services/cliRuntime";
import { getRuntimePorts } from "@/lib/runtime/ports";
const { apiPort } = getRuntimePorts();
/**
* Check if a tool has OmniRoute configured by reading its config file directly.
* This replaces the expensive self-referential HTTP calls to /api/cli-tools/*-settings.
*
* @param toolId - CLI tool identifier (e.g. "claude", "codex", "cline")
* @param _configPathOverride - optional path override (used in tests for DI)
*
* Returns: "configured" | "not_configured" | "not_installed" | "unknown" | "other"
*/
export async function checkToolConfigStatus(
toolId: string,
_configPathOverride?: string
): Promise<"configured" | "not_configured" | "not_installed" | "unknown" | "other"> {
try {
const configPath = _configPathOverride ?? getCliPrimaryConfigPath(toolId);
if (!configPath) return "unknown";
const content = await fs.readFile(configPath, "utf-8");
// Codex uses TOML config — parse as raw text, not JSON
if (toolId === "codex") {
const lower = content.toLowerCase();
const hasOmniRoute =
lower.includes("omniroute") ||
lower.includes(`localhost:${apiPort}`) ||
lower.includes(`127.0.0.1:${apiPort}`);
if (!hasOmniRoute) return "not_configured";
// Also verify auth.json has an API key (not masked/empty)
try {
const authPath = configPath.replace(/config\.toml$/, "auth.json");
const authContent = await fs.readFile(authPath, "utf-8");
const auth = JSON.parse(authContent) as Record<string, unknown>;
const apiKey = (auth?.OPENAI_API_KEY as string) || "";
if (!apiKey || apiKey.includes("****") || apiKey.length < 20) {
return "not_configured";
}
} catch {
return "not_configured";
}
return "configured";
}
if (toolId === "hermes") {
const lower = content.toLowerCase();
const hasOmniRoute =
lower.includes("omniroute") ||
lower.includes(`localhost:${apiPort}`) ||
lower.includes(`127.0.0.1:${apiPort}`);
return hasOmniRoute ? "configured" : "not_configured";
}
const config = JSON.parse(content) as Record<string, unknown>;
// Each tool stores OmniRoute config differently
switch (toolId) {
case "claude":
return (config?.env as Record<string, unknown>)?.ANTHROPIC_BASE_URL
? "configured"
: "not_configured";
case "qwen": {
// Check modelProviders for OmniRoute entries
const mp = config?.modelProviders;
if (!mp) return "not_configured";
const qwenConfigStr = JSON.stringify(mp).toLowerCase();
return qwenConfigStr.includes("omniroute") ||
qwenConfigStr.includes(`localhost:${apiPort}`) ||
qwenConfigStr.includes(`127.0.0.1:${apiPort}`)
? "configured"
: "not_configured";
}
case "droid":
case "openclaw":
case "cline":
case "kilo": {
// Generic check: look for OmniRoute-specific markers in the config
const configStr = JSON.stringify(config).toLowerCase();
if (
configStr.includes("omniroute") ||
configStr.includes("sk_omniroute") ||
configStr.includes(`localhost:${apiPort}`) ||
configStr.includes(`127.0.0.1:${apiPort}`)
) {
return "configured";
}
// Also accept openai-compatible provider with any non-empty baseUrl
// (user may configure an external domain instead of localhost)
if (
toolId === "cline" &&
((config.actModeApiProvider === "openai" || config.planModeApiProvider === "openai") &&
((config.openAiBaseUrl as string) || "").trim().length > 0)
) {
return "configured";
}
return "not_configured";
}
default:
return "unknown";
}
} catch {
return "not_configured";
}
}
+148
View File
@@ -0,0 +1,148 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class CodexCloudAgent extends CloudAgentBase {
readonly providerId = "codex-cloud";
readonly baseUrl = "https://api.openai.com/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repository_context: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
if (params.options.environment) {
body.environment = {
setup: params.options.environment,
};
}
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "pending"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "pending");
const activities: CloudAgentActivity[] = [];
if (data.subagents) {
for (const subagent of data.subagents) {
activities.push({
id: this.generateActivityId(),
type: "command",
content: `Subagent: ${subagent.name} - ${subagent.status}`,
timestamp: new Date().toISOString(),
});
}
}
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && (data.result || data.pr_url)) {
result = {
prUrl: data.pr_url || data.result?.pr_url,
commitMessage: data.result?.commit_message,
summary: data.result?.summary,
duration: data.elapsed_time,
};
}
return {
status,
externalId,
result,
activities,
error: data.error || data.error_message,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Codex Cloud does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}/followup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}
+205
View File
@@ -0,0 +1,205 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentActivity, CloudAgentStatus, CloudAgentTask } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
/**
* Cursor Cloud Agent — drives Cursor's Background / Cloud Agents through its official
* REST API (api.cursor.com) authenticated with a user or service-account API key.
*
* This is the API-key path requested in #4227 as a safer alternative to re-using the
* Cursor IDE's OAuth session (provider `cursor`, which carries a ban-risk warning).
* Like Devin and Jules it is a plain REST adapter — it does NOT pull in the
* `@cursor/sdk` package (which ships per-platform native binaries); Cursor's SDK is
* itself a thin wrapper over this same REST API.
*
* NOTE: the endpoint paths and request/response field names follow Cursor's documented
* Cloud Agents API (v0). They are pending a live validation run against a real Cursor
* API key before this is merged (Rule #18 — external-API integration, see PR notes).
* `baseUrl` is overridable per-credential so the version/path can be corrected without
* a code change.
*/
const CURSOR_DEFAULT_BASE_URL = "https://api.cursor.com/v0";
// Cursor returns UPPERCASE status enums that the base `mapStatus()` substring matcher
// does not recognize (e.g. FINISHED would fall through to "queued"). Map explicitly,
// falling back to the base matcher for anything unforeseen.
const CURSOR_STATUS_MAP: Record<string, CloudAgentStatus> = {
CREATING: CLOUD_AGENT_STATUS.QUEUED,
PENDING: CLOUD_AGENT_STATUS.QUEUED,
QUEUED: CLOUD_AGENT_STATUS.QUEUED,
RUNNING: CLOUD_AGENT_STATUS.RUNNING,
FINISHED: CLOUD_AGENT_STATUS.COMPLETED,
COMPLETED: CLOUD_AGENT_STATUS.COMPLETED,
ERROR: CLOUD_AGENT_STATUS.FAILED,
FAILED: CLOUD_AGENT_STATUS.FAILED,
CANCELLED: CLOUD_AGENT_STATUS.CANCELLED,
EXPIRED: CLOUD_AGENT_STATUS.FAILED,
};
export class CursorCloudAgent extends CloudAgentBase {
readonly providerId = "cursor-cloud";
readonly baseUrl = CURSOR_DEFAULT_BASE_URL;
private resolveBaseUrl(credentials: AgentCredentials): string {
return (credentials.baseUrl || this.baseUrl).replace(/\/$/, "");
}
private mapCursorStatus(raw: string | undefined | null): CloudAgentStatus {
if (!raw) return CLOUD_AGENT_STATUS.QUEUED;
return CURSOR_STATUS_MAP[raw.toUpperCase()] ?? this.mapStatus(raw);
}
private authHeaders(credentials: AgentCredentials, withBody = false): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${credentials.apiKey}`,
};
if (withBody) headers["Content-Type"] = "application/json";
return headers;
}
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const source: Record<string, unknown> = { repository: params.source.repoUrl };
if (params.source.branch) source.ref = params.source.branch;
const body: Record<string, unknown> = {
prompt: { text: params.prompt },
source,
};
if (params.options.autoCreatePr) body.autoCreatePr = true;
const response = await fetch(`${this.resolveBaseUrl(credentials)}/agents`, {
method: "POST",
headers: this.authHeaders(credentials, true),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor create agent failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapCursorStatus(data.status),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(
`${this.resolveBaseUrl(credentials)}/agents/${encodeURIComponent(externalId)}`,
{ headers: this.authHeaders(credentials) }
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor get agent failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapCursorStatus(data.status);
const conversation = Array.isArray(data.conversation) ? data.conversation : [];
const activities: CloudAgentActivity[] = conversation.map((msg: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: "message" as const,
content: typeof msg.text === "string" ? msg.text : "",
timestamp: (msg.createdAt as string) || new Date().toISOString(),
}));
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED) {
const target = (data.target as Record<string, unknown>) || {};
result = {
prUrl: (target.prUrl as string) || (target.url as string) || undefined,
summary: typeof data.summary === "string" ? data.summary : undefined,
};
}
return {
status,
externalId,
result,
activities,
error: typeof data.error === "string" ? data.error : undefined,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Cursor Cloud Agents run autonomously and do not support plan approval");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(
`${this.resolveBaseUrl(credentials)}/agents/${encodeURIComponent(externalId)}/followup`,
{
method: "POST",
headers: this.authHeaders(credentials, true),
body: JSON.stringify({ prompt: { text: message } }),
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor follow-up failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
const response = await fetch(`${this.resolveBaseUrl(credentials)}/repositories`, {
headers: this.authHeaders(credentials),
});
if (!response.ok) return [];
const data = await response.json();
const repos = Array.isArray(data?.repositories)
? data.repositories
: Array.isArray(data)
? data
: [];
return repos
.map((repo: Record<string, unknown>) => {
const url = (repo.url as string) || (repo.repository as string) || "";
if (!url) return null;
const name = (repo.name as string) || url.split("/").slice(-2).join("/");
return { name, url };
})
.filter((entry: { name: string; url: string } | null): entry is { name: string; url: string } =>
entry !== null
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class DevinAgent extends CloudAgentBase {
readonly providerId = "devin";
readonly baseUrl = "https://api.devin.ai/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repo_url: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
const response = await fetch(`${this.baseUrl}/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "created"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "created");
const activities: CloudAgentActivity[] = (data.messages || []).map(
(msg: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: "message" as const,
content: (msg.content as string) || "",
timestamp: (msg.created_at as string) || new Date().toISOString(),
})
);
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.output) {
result = {
prUrl: data.pr_url,
summary: data.output,
duration: data.duration,
};
}
return {
status,
externalId,
result,
activities,
error: data.error,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Devin does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ content: message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}
+354
View File
@@ -0,0 +1,354 @@
import { randomUUID } from "node:crypto";
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import { buildJulesApiUrl, JULES_API_BASE_URL } from "../julesApi.ts";
import type {
CloudAgentTask,
CloudAgentActivity,
CloudAgentStatus,
CloudAgentResult,
} from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
function julesHeaders(apiKey: string, json = false): Record<string, string> {
const headers: Record<string, string> = {
"X-Goog-Api-Key": apiKey,
};
if (json) {
headers["Content-Type"] = "application/json";
}
return headers;
}
function parseGithubOwnerRepo(repoUrl: string, repoName: string): { owner: string; repo: string } {
const normalized = repoUrl.includes("://") ? repoUrl : `https://${repoUrl}`;
try {
const url = new URL(normalized);
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length >= 2) {
return {
owner: parts[0],
repo: parts[1].replace(/\.git$/i, ""),
};
}
} catch {
// fall through to string split for non-URL inputs
}
const parts = repoUrl.split("/").filter(Boolean);
const owner = parts.length >= 2 ? parts[parts.length - 2] : "";
const repo = parts.length >= 2 ? parts[parts.length - 1].replace(/\.git$/i, "") : repoName.trim();
return { owner, repo: repo || repoName.trim() };
}
function buildJulesSourceResourceName(owner: string, repo: string): string {
return `sources/github/${owner}/${repo}`;
}
function normalizeJulesSessionId(externalId: string): string {
const trimmed = externalId.trim();
return trimmed.startsWith("sessions/") ? trimmed.slice("sessions/".length) : trimmed;
}
function mapJulesActivity(act: Record<string, unknown>): CloudAgentActivity {
const progress = act.progressUpdated as Record<string, unknown> | undefined;
const planGenerated = act.planGenerated as Record<string, unknown> | undefined;
let type: CloudAgentActivity["type"] = "command";
let content = "";
if (act.planGenerated) {
type = "plan";
const plan = planGenerated?.plan as Record<string, unknown> | undefined;
const steps = Array.isArray(plan?.steps) ? plan.steps : [];
content = steps
.map((step) => {
const row = step as Record<string, unknown>;
return typeof row.title === "string" ? row.title : "";
})
.filter(Boolean)
.join("\n");
} else if (act.sessionCompleted) {
type = "completion";
content = "Session completed";
} else if (act.planApproved) {
type = "message";
content = "Plan approved";
} else if (progress) {
content = [progress.title, progress.description].filter(Boolean).join(": ");
}
return {
id: (act.id as string) || randomUUID(),
type,
content,
timestamp: (act.createTime as string) || new Date().toISOString(),
};
}
function extractJulesResult(outputs: unknown): CloudAgentResult | undefined {
if (!Array.isArray(outputs)) return undefined;
for (const item of outputs) {
const output = item as Record<string, unknown>;
const pullRequest = output.pullRequest as Record<string, unknown> | undefined;
if (pullRequest?.url) {
return {
prUrl: String(pullRequest.url),
commitMessage:
typeof pullRequest.description === "string" ? pullRequest.description : undefined,
summary: typeof pullRequest.title === "string" ? pullRequest.title : undefined,
};
}
}
return undefined;
}
function readJulesErrorMessage(data: Record<string, unknown>): string {
if (typeof data.error === "string" && data.error.trim()) {
return data.error.trim();
}
if (data.error && typeof data.error === "object") {
const record = data.error as Record<string, unknown>;
const message = record.message;
if (typeof message === "string" && message.trim()) {
return message.trim();
}
}
return "";
}
function inferJulesStatus(
data: Record<string, unknown>,
activities: Record<string, unknown>[]
): CloudAgentStatus {
if (extractJulesResult(data.outputs)) {
return CLOUD_AGENT_STATUS.COMPLETED;
}
if (activities.some((act) => act.sessionCompleted)) {
return CLOUD_AGENT_STATUS.COMPLETED;
}
if (activities.some((act) => act.planGenerated) && !activities.some((act) => act.planApproved)) {
return CLOUD_AGENT_STATUS.AWAITING_APPROVAL;
}
if (readJulesErrorMessage(data)) {
return CLOUD_AGENT_STATUS.FAILED;
}
const state = typeof data.state === "string" ? data.state.toLowerCase() : "";
if (state.includes("failed") || state.includes("error")) {
return CLOUD_AGENT_STATUS.FAILED;
}
if (state.includes("cancelled") || state.includes("canceled")) {
return CLOUD_AGENT_STATUS.CANCELLED;
}
if (state.includes("completed") || state.includes("done")) {
return CLOUD_AGENT_STATUS.COMPLETED;
}
if (state.includes("pending") || state.includes("queued")) {
return CLOUD_AGENT_STATUS.QUEUED;
}
if (state.includes("running") || state.includes("active")) {
return CLOUD_AGENT_STATUS.RUNNING;
}
if (activities.some((act) => act.progressUpdated)) {
return CLOUD_AGENT_STATUS.RUNNING;
}
return CLOUD_AGENT_STATUS.QUEUED;
}
function readJulesSourceBranch(source: Record<string, unknown>): string | undefined {
const githubRepo = source.githubRepo as Record<string, unknown> | undefined;
const githubRepoContext = source.githubRepoContext as Record<string, unknown> | undefined;
const candidates = [
githubRepoContext?.startingBranch,
githubRepoContext?.defaultBranch,
githubRepo?.defaultBranch,
source.defaultBranch,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return undefined;
}
export class JulesAgent extends CloudAgentBase {
readonly providerId = "jules";
readonly baseUrl = JULES_API_BASE_URL;
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const { owner, repo } = parseGithubOwnerRepo(params.source.repoUrl, params.source.repoName);
const sourceResource = buildJulesSourceResourceName(owner, repo);
const body: Record<string, unknown> = {
prompt: params.prompt,
title: params.source.repoName || repo,
sourceContext: {
source: sourceResource,
githubRepoContext: {
startingBranch: params.source.branch || "main",
},
},
};
if (params.options.autoCreatePr) {
body.automationMode = "AUTO_CREATE_PR";
}
if (params.options.planApprovalRequired) {
body.requirePlanApproval = true;
}
const response = await fetch(buildJulesApiUrl("/sessions"), {
method: "POST",
headers: julesHeaders(credentials.apiKey, true),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules create task failed: ${response.status} ${error}`);
}
const data = (await response.json()) as Record<string, unknown>;
const sessionId =
(typeof data.id === "string" && data.id) ||
(typeof data.name === "string" ? normalizeJulesSessionId(data.name) : "") ||
taskId;
return {
id: taskId,
providerId: this.providerId,
externalId: sessionId,
status: CLOUD_AGENT_STATUS.QUEUED,
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const sessionId = normalizeJulesSessionId(externalId);
const [sessionRes, activitiesRes] = await Promise.all([
fetch(buildJulesApiUrl(`/sessions/${sessionId}`), {
headers: julesHeaders(credentials.apiKey),
}),
fetch(buildJulesApiUrl(`/sessions/${sessionId}/activities?pageSize=30`), {
headers: julesHeaders(credentials.apiKey),
}),
]);
if (!sessionRes.ok) {
const error = await sessionRes.text();
throw new Error(`Jules get status failed: ${sessionRes.status} ${error}`);
}
const data = (await sessionRes.json()) as Record<string, unknown>;
let rawActivities: Record<string, unknown>[] = [];
if (activitiesRes.ok) {
const activitiesPayload = (await activitiesRes.json()) as Record<string, unknown>;
rawActivities = Array.isArray(activitiesPayload.activities)
? (activitiesPayload.activities as Record<string, unknown>[])
: [];
}
const activities = rawActivities.map(mapJulesActivity);
const status = inferJulesStatus(data, rawActivities);
const result = extractJulesResult(data.outputs);
const errorMessage = readJulesErrorMessage(data);
return {
status,
externalId: sessionId,
result,
activities,
error: errorMessage || undefined,
};
}
async approvePlan(externalId: string, credentials: AgentCredentials): Promise<void> {
const sessionId = normalizeJulesSessionId(externalId);
const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:approvePlan`), {
method: "POST",
headers: julesHeaders(credentials.apiKey, true),
body: "{}",
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules approve plan failed: ${response.status} ${error}`);
}
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const sessionId = normalizeJulesSessionId(externalId);
const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:sendMessage`), {
method: "POST",
headers: julesHeaders(credentials.apiKey, true),
body: JSON.stringify({ prompt: message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
const response = await fetch(buildJulesApiUrl("/sources"), {
headers: julesHeaders(credentials.apiKey),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules list sources failed: ${response.status} ${error}`);
}
const data = (await response.json()) as Record<string, unknown>;
return (Array.isArray(data.sources) ? data.sources : []).map(
(source: Record<string, unknown>) => {
const githubRepo = source.githubRepo as Record<string, unknown> | undefined;
const owner = typeof githubRepo?.owner === "string" ? githubRepo.owner : "";
const repo = typeof githubRepo?.repo === "string" ? githubRepo.repo : "";
const branch = readJulesSourceBranch(source);
return {
name: typeof source.name === "string" ? source.name : `${owner}/${repo}`,
url: owner && repo ? `https://github.com/${owner}/${repo}` : "",
...(branch ? { branch } : {}),
};
}
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getProviderConnections } from "@/lib/db/providers";
import type { AgentCredentials } from "./baseAgent.ts";
import type { CloudAgentTaskRow } from "./db.ts";
type JsonRecord = Record<string, unknown>;
export function getCloudAgentCorsHeaders(request?: Request) {
const origin = request?.headers.get("origin");
return {
"Access-Control-Allow-Origin": origin || "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "true",
};
}
export function withCloudAgentCors(response: Response, request?: Request): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(getCloudAgentCorsHeaders(request))) {
headers.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
export async function requireCloudAgentManagementAuth(request: Request): Promise<Response | null> {
const authError = await requireManagementAuth(request);
return authError ? withCloudAgentCors(authError, request) : null;
}
function parseJson<T>(value: string | null | undefined, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
export function serializeCloudAgentTask(task: CloudAgentTaskRow) {
return {
id: task.id,
providerId: task.provider_id,
externalId: task.external_id,
status: task.status,
prompt: task.prompt,
source: parseJson<JsonRecord>(task.source, {}),
options: parseJson<JsonRecord>(task.options, {}),
result: task.result ? parseJson<JsonRecord>(task.result, {}) : null,
activities: parseJson<JsonRecord[]>(task.activities, []),
error: task.error,
createdAt: task.created_at,
updatedAt: task.updated_at,
completedAt: task.completed_at,
};
}
function getConnectionToken(connection: JsonRecord): string | null {
const apiKey = typeof connection.apiKey === "string" ? connection.apiKey.trim() : "";
if (apiKey) return apiKey;
const accessToken =
typeof connection.accessToken === "string" ? connection.accessToken.trim() : "";
return accessToken || null;
}
export async function getCloudAgentCredentials(
providerId: string
): Promise<AgentCredentials | null> {
const connections = (await getProviderConnections({
provider: providerId,
isActive: true,
})) as JsonRecord[];
for (const connection of connections) {
const token = getConnectionToken(connection);
if (token) return { apiKey: token };
}
return null;
}
+98
View File
@@ -0,0 +1,98 @@
import { randomBytes } from "node:crypto";
import type {
CloudAgentTask,
CloudAgentStatus,
CloudAgentSource,
CloudAgentResult,
CloudAgentActivity,
} from "./types.ts";
export interface AgentCredentials {
apiKey: string;
baseUrl?: string;
}
export interface CreateTaskParams {
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
}
export interface GetStatusResult {
status: CloudAgentStatus;
externalId?: string;
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
}
export abstract class CloudAgentBase {
abstract readonly providerId: string;
abstract readonly baseUrl: string;
abstract createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask>;
abstract getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult>;
abstract approvePlan(externalId: string, credentials: AgentCredentials): Promise<void>;
abstract sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity>;
abstract listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]>;
protected mapStatus(status: string): CloudAgentStatus {
const statusLower = status.toLowerCase();
if (statusLower.includes("completed") || statusLower.includes("done")) {
return "completed";
}
if (statusLower.includes("failed") || statusLower.includes("error")) {
return "failed";
}
if (statusLower.includes("cancelled") || statusLower.includes("canceled")) {
return "cancelled";
}
if (
statusLower.includes("running") ||
statusLower.includes("active") ||
statusLower.includes("executing")
) {
return "running";
}
if (
statusLower.includes("pending") ||
statusLower.includes("queued") ||
statusLower.includes("waiting")
) {
return "queued";
}
if (statusLower.includes("approval") || statusLower.includes("plan")) {
return "awaiting_approval";
}
return "queued";
}
protected generateTaskId(): string {
// Cryptographically secure suffix (not Math.random) — task IDs flow into
// session/external identifiers, so they must be unpredictable (CodeQL js/insecure-randomness).
return `task_${Date.now()}_${randomBytes(8).toString("hex")}`;
}
protected generateActivityId(): string {
return `act_${Date.now()}_${randomBytes(8).toString("hex")}`;
}
}
+89
View File
@@ -0,0 +1,89 @@
import { getDbInstance } from "@/lib/db/core";
import { encrypt, decrypt } from "@/lib/db/encryption";
import type { AgentCredentials } from "@/lib/cloudAgent/baseAgent";
// The `cloud_agent_credentials` table is provisioned by migration
// `061_cloud_agent_credentials.sql` at database initialization (see
// src/lib/db/migrations/). Do not create it inline here — the project
// migration policy requires versioned, transaction-wrapped DDL.
/** Mask API key for display — show last 4 chars only */
export function maskApiKey(key: string): string {
if (!key || key.length <= 4) return "****";
return "****" + key.slice(-4);
}
/** Get decrypted credentials for a provider */
export function getCloudAgentCredentialFromDb(providerId: string): AgentCredentials | null {
const db = getDbInstance();
const row = db
.prepare(
"SELECT api_key_encrypted, base_url FROM cloud_agent_credentials WHERE provider_id = ?"
)
.get(providerId) as { api_key_encrypted: string; base_url: string | null } | undefined;
if (!row) return null;
const decryptedKey = decrypt(row.api_key_encrypted);
if (!decryptedKey) return null;
const creds: AgentCredentials = { apiKey: decryptedKey };
if (row.base_url) creds.baseUrl = row.base_url;
return creds;
}
/** List all credentials with masked keys */
export function listCloudAgentCredentials(): Array<{
providerId: string;
apiKey: string;
baseUrl: string | null;
updatedAt: string;
}> {
const db = getDbInstance();
const rows = db
.prepare(
"SELECT provider_id, api_key_encrypted, base_url, updated_at FROM cloud_agent_credentials"
)
.all() as {
provider_id: string;
api_key_encrypted: string;
base_url: string | null;
updated_at: string;
}[];
return rows.map((row) => {
const decrypted = decrypt(row.api_key_encrypted) ?? "";
return {
providerId: row.provider_id,
apiKey: maskApiKey(decrypted),
baseUrl: row.base_url,
updatedAt: row.updated_at,
};
});
}
/** Save or update credentials (encrypts API key at rest) */
export function saveCloudAgentCredential(
providerId: string,
apiKey: string,
baseUrl?: string
): void {
const encrypted = encrypt(apiKey);
if (!encrypted) throw new Error("Failed to encrypt API key");
const db = getDbInstance();
db.prepare(
`INSERT INTO cloud_agent_credentials (provider_id, api_key_encrypted, base_url, updated_at)
VALUES (@providerId, @apiKey, @baseUrl, datetime('now'))
ON CONFLICT(provider_id) DO UPDATE SET
api_key_encrypted = excluded.api_key_encrypted,
base_url = excluded.base_url,
updated_at = excluded.updated_at`
).run({ providerId, apiKey: encrypted, baseUrl: baseUrl ?? null });
}
/** Delete credentials for a provider */
export function deleteCloudAgentCredential(providerId: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM cloud_agent_credentials WHERE provider_id = ?").run(providerId);
}
+145
View File
@@ -0,0 +1,145 @@
import { getDbInstance } from "@/lib/db/core.ts";
export interface CloudAgentTaskRow {
id: string;
provider_id: string;
external_id: string | null;
status: string;
prompt: string;
source: string;
options: string;
result: string | null;
activities: string;
error: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export function createCloudAgentTaskTable(): void {
const db = getDbInstance();
db.exec(`
CREATE TABLE IF NOT EXISTS cloud_agent_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
external_id TEXT,
status TEXT NOT NULL DEFAULT 'queued',
prompt TEXT NOT NULL,
source TEXT NOT NULL,
options TEXT DEFAULT '{}',
result TEXT,
activities TEXT DEFAULT '[]',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_provider
ON cloud_agent_tasks(provider_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_status
ON cloud_agent_tasks(status)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_created
ON cloud_agent_tasks(created_at DESC)
`);
}
export function insertCloudAgentTask(task: CloudAgentTaskRow): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO cloud_agent_tasks (
id, provider_id, external_id, status, prompt, source,
options, result, activities, error, created_at, updated_at, completed_at
) VALUES (
@id, @provider_id, @external_id, @status, @prompt, @source,
@options, @result, @activities, @error, @created_at, @updated_at, @completed_at
)
`
).run(task);
}
// Whitelist of allowed columns for update operations
const ALLOWED_UPDATE_COLUMNS = new Set([
"status",
"prompt",
"source",
"options",
"result",
"activities",
"error",
"completed_at",
]);
export function updateCloudAgentTask(
id: string,
updates: Partial<Omit<CloudAgentTaskRow, "id">>
): void {
const db = getDbInstance();
// Validate keys against whitelist to prevent SQL injection
const validUpdates: Partial<Omit<CloudAgentTaskRow, "id">> = {};
for (const [key, value] of Object.entries(updates)) {
if (ALLOWED_UPDATE_COLUMNS.has(key)) {
(validUpdates as Record<string, unknown>)[key] = value;
}
}
const fields = Object.keys(validUpdates)
.map((key) => `${key} = @${key}`)
.join(", ");
if (!fields) return; // No valid updates
db.prepare(
`
UPDATE cloud_agent_tasks
SET ${fields}, updated_at = datetime('now')
WHERE id = @id
`
).run({ id, ...validUpdates });
}
export function getCloudAgentTaskById(id: string): CloudAgentTaskRow | null {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE id = ?")
.get(id) as CloudAgentTaskRow | null;
}
export function getCloudAgentTasksByProvider(providerId: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare(
"SELECT * FROM cloud_agent_tasks WHERE provider_id = ? ORDER BY created_at DESC LIMIT ?"
)
.all(providerId, limit) as CloudAgentTaskRow[];
}
export function getCloudAgentTasksByStatus(status: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?")
.all(status, limit) as CloudAgentTaskRow[];
}
export function getAllCloudAgentTasks(limit = 100): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks ORDER BY created_at DESC LIMIT ?")
.all(limit) as CloudAgentTaskRow[];
}
export function deleteCloudAgentTask(id: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM cloud_agent_tasks WHERE id = ?").run(id);
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./types.ts";
export * from "./baseAgent.ts";
export * from "./registry.ts";
export * from "./db.ts";
import { createCloudAgentTaskTable } from "./db.ts";
createCloudAgentTaskTable();
+7
View File
@@ -0,0 +1,7 @@
/** Jules REST API base — https://developers.google.com/jules/api */
export const JULES_API_BASE_URL = "https://jules.googleapis.com/v1alpha";
export function buildJulesApiUrl(path: string): string {
const normalized = path.startsWith("/") ? path : `/${path}`;
return `${JULES_API_BASE_URL}${normalized}`;
}
+29
View File
@@ -0,0 +1,29 @@
import type { CloudAgentBase } from "./baseAgent.ts";
import { JulesAgent } from "./agents/jules.ts";
import { DevinAgent } from "./agents/devin.ts";
import { CodexCloudAgent } from "./agents/codex.ts";
import { CursorCloudAgent } from "./agents/cursor.ts";
const AGENTS: Record<string, CloudAgentBase> = {
jules: new JulesAgent(),
devin: new DevinAgent(),
"codex-cloud": new CodexCloudAgent(),
// #4227: Cursor Background/Cloud Agents via the official REST API (API-key based,
// no IDE-OAuth ban risk). Distinct provider id from the OAuth chat provider `cursor`.
"cursor-cloud": new CursorCloudAgent(),
};
export function getAgent(providerId: string): CloudAgentBase | null {
return AGENTS[providerId] || null;
}
export function getAvailableAgents(): string[] {
return Object.keys(AGENTS);
}
export function isCloudAgentProvider(providerId: string): boolean {
return providerId in AGENTS;
}
export { JulesAgent, DevinAgent, CodexCloudAgent, CursorCloudAgent };
export type { CloudAgentBase } from "./baseAgent.ts";
+90
View File
@@ -0,0 +1,90 @@
import { z } from "zod";
export const CLOUD_AGENT_STATUS = {
QUEUED: "queued",
RUNNING: "running",
AWAITING_APPROVAL: "awaiting_approval",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type CloudAgentStatus = (typeof CLOUD_AGENT_STATUS)[keyof typeof CLOUD_AGENT_STATUS];
export const CloudAgentStatusSchema = z.enum([
"queued",
"running",
"awaiting_approval",
"completed",
"failed",
"cancelled",
]);
export interface CloudAgentSource {
repoName: string;
repoUrl: string;
branch?: string;
}
export interface CloudAgentResult {
prUrl?: string;
prNumber?: number;
commitMessage?: string;
diffUrl?: string;
summary?: string;
duration?: number;
cost?: number;
}
export interface CloudAgentActivity {
id: string;
type: "plan" | "command" | "code_change" | "message" | "error" | "completion";
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface CloudAgentTask {
id: string;
providerId: "jules" | "devin" | "codex-cloud" | "cursor-cloud";
externalId?: string;
status: CloudAgentStatus;
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export const CloudAgentSourceSchema = z.object({
repoName: z.string().min(1),
repoUrl: z.string().url(),
branch: z.string().optional(),
});
export const CloudAgentTaskOptionsSchema = z.object({
autoCreatePr: z.boolean().optional(),
planApprovalRequired: z.boolean().optional(),
environment: z.record(z.string(), z.string()).optional(),
});
export const CreateCloudAgentTaskSchema = z.object({
providerId: z.enum(["jules", "devin", "codex-cloud", "cursor-cloud"]),
prompt: z.string().min(1).max(10000),
source: CloudAgentSourceSchema,
options: CloudAgentTaskOptionsSchema.optional(),
});
export const UpdateCloudAgentTaskSchema = z.object({
id: z.string().min(1),
action: z.enum(["approve", "reject", "cancel", "message"]),
message: z.string().optional(),
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Stub for `src/lib/cloudSync.ts` activated by
* `OMNIROUTE_BUILD_PROFILE=minimal`. All Cloud Sync code paths
* (signature verification, remote-credential merge, fetch with timeout) are
* physically absent from the built bundle. See SECURITY.md and
* docs/security/SOCKET_DEV_FINDINGS.md.
*/
import { featureDisabledError } from "@/lib/build-profile/featureDisabled";
const FEATURE = "cloud-sync";
export const CLOUD_URL = "";
export const CLOUD_SYNC_TIMEOUT_MS = 0;
export const CLOUD_SYNC_SECRETS_ENABLED = false;
export function verifyCloudSignature(_rawBody: string, _sigHeader: string | null): boolean {
return false;
}
export async function fetchWithTimeout(): Promise<never> {
throw featureDisabledError(FEATURE);
}
export async function syncToCloud(
_machineId: string,
_createdKey: string | null = null
): Promise<{ error: string }> {
// Soft-fail instead of throwing so the caller (api/keys/[id]/route.ts) can
// continue serving the rest of the request — Cloud sync is best-effort.
return { error: "Cloud Sync is disabled in this build (minimal profile)" };
}
+210
View File
@@ -0,0 +1,210 @@
import crypto from "crypto";
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
import { buildConfigSyncEnvelope, toLegacyCloudSyncPayload } from "@/lib/sync/bundle";
const CLOUD_URL = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL;
const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000);
const CLOUD_SYNC_SECRET = process.env.OMNIROUTE_CLOUD_SYNC_SECRET || "";
// Opt-in: only when explicitly set to "true" will updateLocalTokens overwrite
// accessToken/refreshToken/providerSpecificData from the Cloud response. Default
// behaviour from v3.8.6 onward syncs only non-credential metadata (expiresAt,
// status, lastError*, rateLimitedUntil, updatedAt) so a misconfigured or
// hostile CLOUD_URL cannot silently swap user OAuth tokens.
const CLOUD_SYNC_SECRETS_ENABLED = process.env.OMNIROUTE_CLOUD_SYNC_SECRETS === "true";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toDateMs(value: unknown): number {
if (typeof value === "string" || typeof value === "number" || value instanceof Date) {
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
// SECURITY-AUDITOR-NOTE: HMAC signature verification of the Cloud response.
// Closes the silent-credential-swap surface flagged by Socket.dev (finding for
// `app/.next/server/app/api/keys/[id]/route.js`). Two-leg defence:
// 1. The Cloud endpoint signs each response body with
// `HMAC-SHA256(OMNIROUTE_CLOUD_SYNC_SECRET, rawBody)` and returns the hex
// digest in `X-Cloud-Sig`.
// 2. We verify the signature with `crypto.timingSafeEqual` before parsing the
// JSON, so a MITM on the CLOUD_URL channel — or a misconfigured CLOUD_URL
// pointing at an attacker — cannot inject providers/tokens.
// If `OMNIROUTE_CLOUD_SYNC_SECRET` is unset, signature validation is logged but
// not enforced (back-compat for users on v3.8.x who haven't issued a shared
// secret yet). The enforce-by-default switch will flip in v3.9.
export function verifyCloudSignature(rawBody: string, sigHeader: string | null): boolean {
if (!CLOUD_SYNC_SECRET) {
if (sigHeader) {
// We can't verify, but the server is at least trying. Pass through.
return true;
}
console.warn(
"[cloudSync] OMNIROUTE_CLOUD_SYNC_SECRET is not set and the Cloud response carries no X-Cloud-Sig. " +
"Token sync runs in legacy unverified mode — set the secret to enforce HMAC verification."
);
return true;
}
if (!sigHeader) {
console.warn("[cloudSync] Cloud response missing X-Cloud-Sig — rejecting payload.");
return false;
}
const expected = crypto.createHmac("sha256", CLOUD_SYNC_SECRET).update(rawBody).digest("hex");
try {
const expectedBuf = Buffer.from(expected, "hex");
const actualBuf = Buffer.from(sigHeader, "hex");
if (expectedBuf.length !== actualBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, actualBuf);
} catch {
return false;
}
}
export async function fetchWithTimeout(url, options = {}, timeoutMs = CLOUD_SYNC_TIMEOUT_MS) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
/**
* Sync data to Cloud (shared utility)
* @param {string} machineId
* @param {string|null} createdKey - Key created during enable
*/
export async function syncToCloud(machineId, createdKey = null) {
if (!CLOUD_URL) {
return { error: "NEXT_PUBLIC_CLOUD_URL is not configured" };
}
// Keep legacy field names for upstream compatibility, but derive them
// from a canonical sync bundle with deterministic version hashing.
const { version, bundle } = await buildConfigSyncEnvelope();
const legacyPayload = toLegacyCloudSyncPayload(bundle);
let response;
try {
// Send to Cloud
response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...legacyPayload,
version,
}),
});
} catch (error) {
const isTimeout = error?.name === "AbortError";
return { error: isTimeout ? "Cloud sync timeout" : "Cloud sync request failed" };
}
if (!response.ok) {
const errorText = await response.text();
const truncated = errorText.length > 200 ? errorText.slice(0, 200) + "…" : errorText;
console.log("Cloud sync failed", { status: response.status, body: truncated });
return { error: "Cloud sync failed" };
}
// Read the raw body so we can verify the signature before trusting any
// JSON-parsed field. Order matters: parse only after verification passes.
const rawBody = await response.text();
const sigHeader = response.headers.get("X-Cloud-Sig");
if (!verifyCloudSignature(rawBody, sigHeader)) {
return { error: "Cloud sync signature verification failed" };
}
let result: any;
try {
result = JSON.parse(rawBody);
} catch {
return { error: "Cloud sync response is not valid JSON" };
}
// Update local db with tokens from Cloud (providers stored by ID)
if (result?.data?.providers) {
await updateLocalTokens(result.data.providers);
}
const responseData: any = {
success: true,
message: "Synced successfully",
changes: result.changes,
version,
};
if (createdKey) {
responseData.createdKey = createdKey;
}
return responseData;
}
/**
* Update local db with data from Cloud
* Simple logic: if Cloud is newer, sync entire provider
* cloudProviders is object keyed by provider ID
*
* SECURITY-AUDITOR-NOTE: This function appears in Socket.dev finding for
* `app/.next/server/app/api/keys/[id]/route.js`. From v3.8.6 onward,
* `accessToken` / `refreshToken` / `providerSpecificData` are only synced when
* `OMNIROUTE_CLOUD_SYNC_SECRETS=true`. The default mode syncs non-credential
* metadata only. Combined with `verifyCloudSignature()` above, this closes the
* silent-credential-overwrite path. See docs/security/SOCKET_DEV_FINDINGS.md §5.
*/
async function updateLocalTokens(cloudProviders: unknown) {
const cloudProvidersMap = asRecord(cloudProviders);
const localProviders = await getProviderConnections();
for (const localProviderRaw of localProviders as unknown[]) {
const localProvider = asRecord(localProviderRaw);
const localProviderId = toStringOrNull(localProvider.id);
if (!localProviderId) continue;
const cloudProvider = asRecord(cloudProvidersMap[localProviderId]);
if (Object.keys(cloudProvider).length === 0) continue;
const cloudUpdatedAt = toDateMs(cloudProvider.updatedAt);
const localUpdatedAt = toDateMs(localProvider.updatedAt);
if (cloudUpdatedAt > localUpdatedAt) {
const updates: Record<string, unknown> = {
// Non-credential metadata — always synced.
expiresAt: cloudProvider.expiresAt,
expiresIn: cloudProvider.expiresIn,
testStatus: cloudProvider.status || "active",
lastError: cloudProvider.lastError,
lastErrorAt: cloudProvider.lastErrorAt,
errorCode: cloudProvider.errorCode,
rateLimitedUntil: cloudProvider.rateLimitedUntil,
updatedAt: cloudProvider.updatedAt,
};
// Credentials and providerSpecificData are only overwritten when the
// operator has explicitly opted in to remote credential sync. Default
// OFF closes the silent-swap surface.
if (CLOUD_SYNC_SECRETS_ENABLED) {
updates.accessToken = cloudProvider.accessToken;
updates.refreshToken = cloudProvider.refreshToken;
updates.providerSpecificData =
cloudProvider.providerSpecificData || localProvider.providerSpecificData;
}
await updateProviderConnection(localProviderId, updates);
}
}
}
export { CLOUD_URL, CLOUD_SYNC_TIMEOUT_MS, CLOUD_SYNC_SECRETS_ENABLED };
+933
View File
@@ -0,0 +1,933 @@
import { spawn, execFile } from "child_process";
import { createHash } from "crypto";
import { promisify } from "util";
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import proxyFetch from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveDataDir } from "@/lib/dataPaths";
import { getRuntimePorts } from "@/lib/runtime/ports";
const execFileAsync = promisify(execFile);
const CLOUDFLARED_RELEASE_API_URL =
"https://api.github.com/repos/cloudflare/cloudflared/releases/latest";
const START_TIMEOUT_MS = 30000;
const STOP_TIMEOUT_MS = 5000;
const GENERIC_EXIT_ERROR_PREFIX = "cloudflared exited";
const DEFAULT_CERT_FILE_CANDIDATES = [
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/cert.pem",
"/private/etc/ssl/cert.pem",
] as const;
const DEFAULT_CERT_DIR_CANDIDATES = [
"/etc/ssl/certs",
"/etc/pki/tls/certs",
"/system/etc/security/cacerts",
] as const;
type CloudflaredInstallSource = "managed" | "path" | "env";
type TunnelPhase = "unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
type AssetSpec = {
assetName: string;
binaryName: string;
archive: "none" | "tgz";
};
type ResolvedAssetSpec = AssetSpec & {
downloadUrl: string;
expectedSha256: string;
};
type CloudflaredRuntimeDirs = {
runtimeRoot: string;
homeDir: string;
configDir: string;
cacheDir: string;
dataDir: string;
tempDir: string;
userProfileDir: string;
appDataDir: string;
localAppDataDir: string;
};
type BinaryResolution = {
binaryPath: string | null;
source: CloudflaredInstallSource | null;
managed: boolean;
};
type PersistedTunnelState = {
binaryPath?: string | null;
installSource?: CloudflaredInstallSource | null;
ownerPid?: number | null;
pid?: number | null;
publicUrl?: string | null;
apiUrl?: string | null;
targetUrl?: string | null;
status?: TunnelPhase;
lastError?: string | null;
startedAt?: string | null;
installedAt?: string | null;
};
export type CloudflaredTunnelStatus = {
supported: boolean;
installed: boolean;
managedInstall: boolean;
installSource: CloudflaredInstallSource | null;
binaryPath: string | null;
running: boolean;
pid: number | null;
publicUrl: string | null;
apiUrl: string | null;
targetUrl: string;
phase: TunnelPhase;
lastError: string | null;
logPath: string;
};
const CLOUDFLARED_SAFE_ENV_KEYS = [
"PATH",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"PROGRAMDATA",
"ProgramData",
"SYSTEMROOT",
"SystemRoot",
"WINDIR",
"ComSpec",
"COMSPEC",
"PATHEXT",
"TMPDIR",
"TMP",
"TEMP",
"USER",
"USERNAME",
"LOGNAME",
"SHELL",
"LANG",
"LC_ALL",
"LC_CTYPE",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
] as const;
let tunnelProcess: ReturnType<typeof spawn> | null = null;
let tunnelPid: number | null = null;
let installPromise: Promise<string> | null = null;
let startPromise: Promise<CloudflaredTunnelStatus> | null = null;
let stateFileQueue: Promise<void> = Promise.resolve();
const NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS = [
/failed to sufficiently increase receive buffer size/i,
] as const;
function getTunnelDir() {
return path.join(resolveDataDir(), "cloudflared");
}
function getManagedBinaryPath(platform = process.platform) {
return path.join(getTunnelDir(), "bin", platform === "win32" ? "cloudflared.exe" : "cloudflared");
}
function getStateFilePath() {
return path.join(getTunnelDir(), "quick-tunnel-state.json");
}
function getPidFilePath() {
return path.join(getTunnelDir(), ".quick-tunnel.pid");
}
function getLogFilePath() {
return path.join(getTunnelDir(), "quick-tunnel.log");
}
export function getCloudflaredRuntimeDirs(): CloudflaredRuntimeDirs {
const runtimeRoot = path.join(getTunnelDir(), "runtime");
const homeDir = path.join(runtimeRoot, "home");
const userProfileDir = path.join(runtimeRoot, "userprofile");
return {
runtimeRoot,
homeDir,
configDir: path.join(runtimeRoot, "config"),
cacheDir: path.join(runtimeRoot, "cache"),
dataDir: path.join(runtimeRoot, "data"),
tempDir: path.join(runtimeRoot, "tmp"),
userProfileDir,
appDataDir: path.join(userProfileDir, "AppData", "Roaming"),
localAppDataDir: path.join(userProfileDir, "AppData", "Local"),
};
}
function getLocalTargetUrl() {
const { apiPort } = getRuntimePorts();
return `http://127.0.0.1:${apiPort}`;
}
function getTunnelApiUrl(publicUrl: string | null) {
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
}
async function ensureTunnelDir() {
await fs.mkdir(path.join(getTunnelDir(), "bin"), { recursive: true });
}
async function ensureTunnelRuntimeDirs() {
const runtimeDirs = getCloudflaredRuntimeDirs();
await Promise.all(
Object.values(runtimeDirs).map((dirPath) => fs.mkdir(dirPath, { recursive: true }))
);
}
async function readStateFile(): Promise<PersistedTunnelState> {
try {
const content = await fs.readFile(getStateFilePath(), "utf8");
return JSON.parse(content) as PersistedTunnelState;
} catch {
return {};
}
}
async function writeStateFileNow(state: PersistedTunnelState) {
const stateFilePath = getStateFilePath();
await fs.mkdir(path.dirname(stateFilePath), { recursive: true });
await fs.writeFile(stateFilePath, JSON.stringify(state, null, 2) + "\n", "utf8");
}
async function withStateFileLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = stateFileQueue;
let release!: () => void;
stateFileQueue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
}
}
async function writeStateFile(state: PersistedTunnelState) {
await withStateFileLock(() => writeStateFileNow(state));
}
async function updateStateFile(patch: PersistedTunnelState) {
await withStateFileLock(async () => {
const current = await readStateFile();
await writeStateFileNow({ ...current, ...patch });
});
}
async function clearPidFile() {
try {
await fs.unlink(getPidFilePath());
} catch {
// Ignore missing/stale pid files.
}
}
async function writePidFile(pid: number) {
await ensureTunnelDir();
await fs.writeFile(getPidFilePath(), String(pid), "utf8");
}
async function readPidFile() {
try {
const content = await fs.readFile(getPidFilePath(), "utf8");
const pid = Number.parseInt(content.trim(), 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
function isProcessAlive(pid: number | null) {
if (!pid || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function appendTunnelLog(source: "stdout" | "stderr", message: string) {
await ensureTunnelDir();
const timestamp = new Date().toISOString();
await fs.appendFile(getLogFilePath(), `[${timestamp}] [${source}] ${message}\n`, "utf8");
}
export function extractTryCloudflareUrl(text: string) {
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i);
if (!match) return null;
try {
const hostname = new URL(match[0]).hostname.toLowerCase();
if (hostname === "api.trycloudflare.com") return null;
} catch {
return null;
}
return match[0];
}
function normalizeCloudflaredLogLine(line: string) {
return line
.trim()
.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:INF|WRN|ERR)\s+/i, "")
.trim();
}
export function extractCloudflaredErrorMessage(text: string) {
const lines = String(text || "")
.split(/\r?\n/)
.map(normalizeCloudflaredLogLine)
.filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
if (NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS.some((pattern) => pattern.test(lines[i]))) {
continue;
}
if (/(?:\berror\b|\bfailed\b|\btls:\b|\bx509\b|\bcertificate\b)/i.test(lines[i])) {
return lines[i];
}
}
return null;
}
function isSpecificCloudflaredError(error: string | null | undefined) {
return !!error && !error.startsWith(GENERIC_EXIT_ERROR_PREFIX);
}
function getGenericExitError(code: number | null, signal: NodeJS.Signals | null) {
return `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`;
}
export function getDefaultCloudflaredCertEnv(
existsSync: (candidate: string) => boolean = fsSync.existsSync,
certFileCandidates: readonly string[] = DEFAULT_CERT_FILE_CANDIDATES,
certDirCandidates: readonly string[] = DEFAULT_CERT_DIR_CANDIDATES
) {
const certEnv: NodeJS.ProcessEnv = {};
const certFile = certFileCandidates.find((candidate) => existsSync(candidate));
const certDir = certDirCandidates.find((candidate) => existsSync(candidate));
if (certFile) certEnv.SSL_CERT_FILE = certFile;
if (certDir) certEnv.SSL_CERT_DIR = certDir;
return certEnv;
}
export function buildCloudflaredChildEnv(
sourceEnv: NodeJS.ProcessEnv = process.env,
runtimeDirs: CloudflaredRuntimeDirs = getCloudflaredRuntimeDirs(),
defaultCertEnv: NodeJS.ProcessEnv = getDefaultCloudflaredCertEnv()
): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = {};
for (const key of CLOUDFLARED_SAFE_ENV_KEYS) {
const value = sourceEnv[key];
if (typeof value === "string" && value.length > 0) {
childEnv[key] = value;
}
}
childEnv.HOME = runtimeDirs.homeDir;
childEnv.XDG_CONFIG_HOME = runtimeDirs.configDir;
childEnv.XDG_CACHE_HOME = runtimeDirs.cacheDir;
childEnv.XDG_DATA_HOME = runtimeDirs.dataDir;
childEnv.USERPROFILE = runtimeDirs.userProfileDir;
childEnv.APPDATA = runtimeDirs.appDataDir;
childEnv.LOCALAPPDATA = runtimeDirs.localAppDataDir;
if (!childEnv.TMPDIR) childEnv.TMPDIR = runtimeDirs.tempDir;
if (!childEnv.TMP) childEnv.TMP = runtimeDirs.tempDir;
if (!childEnv.TEMP) childEnv.TEMP = runtimeDirs.tempDir;
if (!childEnv.SSL_CERT_FILE && defaultCertEnv.SSL_CERT_FILE) {
childEnv.SSL_CERT_FILE = defaultCertEnv.SSL_CERT_FILE;
}
if (!childEnv.SSL_CERT_DIR && defaultCertEnv.SSL_CERT_DIR) {
childEnv.SSL_CERT_DIR = defaultCertEnv.SSL_CERT_DIR;
}
const requestedProtocol = String(
sourceEnv.CLOUDFLARED_PROTOCOL || sourceEnv.TUNNEL_TRANSPORT_PROTOCOL || "http2"
)
.trim()
.toLowerCase();
const protocol =
requestedProtocol === "quic" || requestedProtocol === "auto" ? requestedProtocol : "http2";
if (protocol !== "auto") {
childEnv.TUNNEL_TRANSPORT_PROTOCOL = protocol;
}
return childEnv;
}
export function getCloudflaredStartArgs(targetUrl: string) {
return ["tunnel", "--url", targetUrl, "--no-autoupdate"];
}
function isStateOwnedByCurrentProcess(state: PersistedTunnelState) {
return !!state.ownerPid && state.ownerPid === process.pid;
}
function hasTransientRuntimeState(state: PersistedTunnelState) {
return !!(
state.ownerPid ||
state.pid ||
state.publicUrl ||
state.apiUrl ||
state.startedAt ||
state.status === "running" ||
state.status === "starting" ||
state.status === "error"
);
}
function buildStoppedState(
state: PersistedTunnelState,
binaryResolved: boolean,
targetUrl = getLocalTargetUrl()
): PersistedTunnelState {
return {
...state,
ownerPid: null,
pid: null,
publicUrl: null,
apiUrl: null,
targetUrl,
status: binaryResolved ? "stopped" : "not_installed",
lastError: null,
startedAt: null,
};
}
export function getCloudflaredAssetSpec(
platform = process.platform,
arch = process.arch
): AssetSpec | null {
const matrix: Record<string, Record<string, AssetSpec>> = {
linux: {
x64: {
assetName: "cloudflared-linux-amd64",
binaryName: "cloudflared",
archive: "none",
},
arm64: {
assetName: "cloudflared-linux-arm64",
binaryName: "cloudflared",
archive: "none",
},
},
darwin: {
x64: {
assetName: "cloudflared-darwin-amd64.tgz",
binaryName: "cloudflared",
archive: "tgz",
},
arm64: {
assetName: "cloudflared-darwin-arm64.tgz",
binaryName: "cloudflared",
archive: "tgz",
},
},
win32: {
x64: {
assetName: "cloudflared-windows-amd64.exe",
binaryName: "cloudflared.exe",
archive: "none",
},
arm64: {
assetName: "cloudflared-windows-arm64.exe",
binaryName: "cloudflared.exe",
archive: "none",
},
},
};
const spec = matrix[platform]?.[arch];
if (!spec) return null;
return spec;
}
export function getSha256FromGitHubDigest(digest: string): string | null {
const prefix = "sha256:";
if (!digest.toLowerCase().startsWith(prefix)) return null;
const value = digest.slice(prefix.length);
if (value.length !== 64) return null;
for (const char of value) {
const code = char.charCodeAt(0);
const digit = code >= 48 && code <= 57;
const lowerHex = code >= 97 && code <= 102;
const upperHex = code >= 65 && code <= 70;
if (!digit && !lowerHex && !upperHex) return null;
}
return value.toLowerCase();
}
export function verifyCloudflaredDownloadDigest(
buffer: Buffer,
expectedSha256: string,
assetName = "cloudflared"
): void {
const actualSha256 = createHash("sha256").update(buffer).digest("hex");
if (actualSha256 !== expectedSha256.toLowerCase()) {
throw new Error(
`cloudflared download checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}`
);
}
}
async function resolveCloudflaredDownloadSpec(spec: AssetSpec): Promise<ResolvedAssetSpec> {
const response = await proxyFetch(CLOUDFLARED_RELEASE_API_URL, {
headers: { Accept: "application/vnd.github+json" },
redirect: "follow",
});
if (!response.ok) {
throw new Error(
`Failed to resolve cloudflared release metadata with status ${response.status}`
);
}
const release = (await response.json()) as { assets?: unknown };
const assets = Array.isArray(release.assets) ? release.assets : [];
const asset = assets
.map((entry) =>
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null
)
.find((entry) => entry?.name === spec.assetName);
if (!asset) {
throw new Error(`cloudflared release asset not found: ${spec.assetName}`);
}
const downloadUrl =
typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
const digest = typeof asset.digest === "string" ? asset.digest : "";
const expectedSha256 = getSha256FromGitHubDigest(digest);
if (!downloadUrl || !expectedSha256) {
throw new Error(`cloudflared release asset ${spec.assetName} is missing a sha256 digest`);
}
return {
...spec,
downloadUrl,
expectedSha256,
};
}
async function resolvePathCommand(command: string) {
const lookupCommand = process.platform === "win32" ? "where" : "which";
const args = [command];
try {
const { stdout } = await execFileAsync(lookupCommand, args, { timeout: 3000 });
const first = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
return first || null;
} catch {
return null;
}
}
async function resolveBinary(): Promise<BinaryResolution> {
const envPath = String(process.env.CLOUDFLARED_BIN || "").trim();
if (envPath && fsSync.existsSync(envPath)) {
return { binaryPath: envPath, source: "env", managed: false };
}
const managedPath = getManagedBinaryPath();
if (fsSync.existsSync(managedPath)) {
return { binaryPath: managedPath, source: "managed", managed: true };
}
const pathBinary = await resolvePathCommand("cloudflared");
if (pathBinary) {
return { binaryPath: pathBinary, source: "path", managed: false };
}
return { binaryPath: null, source: null, managed: false };
}
async function extractArchive(archivePath: string, destinationDir: string) {
await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir], { timeout: 15000 });
}
async function downloadToFile(
url: string,
destinationPath: string,
expectedSha256: string,
assetName: string
) {
const response = await proxyFetch(url, { redirect: "follow" });
if (!response.ok) {
throw new Error(`Download failed with status ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
verifyCloudflaredDownloadDigest(buffer, expectedSha256, assetName);
await fs.writeFile(destinationPath, buffer);
}
async function ensureExecutable(binaryPath: string) {
if (process.platform !== "win32") {
await fs.chmod(binaryPath, 0o755);
}
}
async function installManagedBinary() {
if (installPromise) return installPromise;
installPromise = (async () => {
const spec = getCloudflaredAssetSpec();
if (!spec) {
throw new Error(
`Unsupported platform for managed cloudflared install: ${process.platform}/${process.arch}`
);
}
await ensureTunnelDir();
const managedBinaryPath = getManagedBinaryPath();
const tempDownloadPath = path.join(getTunnelDir(), `${spec.assetName}.download`);
await updateStateFile({
status: "starting",
lastError: null,
});
try {
const downloadSpec = await resolveCloudflaredDownloadSpec(spec);
await downloadToFile(
downloadSpec.downloadUrl,
tempDownloadPath,
downloadSpec.expectedSha256,
downloadSpec.assetName
);
if (spec.archive === "tgz") {
await extractArchive(tempDownloadPath, path.dirname(managedBinaryPath));
} else {
await fs.rename(tempDownloadPath, managedBinaryPath);
}
await ensureExecutable(managedBinaryPath);
await updateStateFile({
binaryPath: managedBinaryPath,
installSource: "managed",
installedAt: new Date().toISOString(),
lastError: null,
});
return managedBinaryPath;
} finally {
try {
await fs.unlink(tempDownloadPath);
} catch {
// Ignore temp cleanup issues.
}
installPromise = null;
}
})();
return installPromise;
}
async function ensureBinary() {
const resolved = await resolveBinary();
if (resolved.binaryPath) {
return resolved;
}
const binaryPath = await installManagedBinary();
return {
binaryPath,
source: "managed" as const,
managed: true,
};
}
async function finalizeProcessExit(code: number | null, signal: NodeJS.Signals | null) {
const currentState = await readStateFile();
const lastError =
code === 0 || signal === "SIGTERM" || signal === "SIGINT"
? null
: isSpecificCloudflaredError(currentState.lastError)
? currentState.lastError
: getGenericExitError(code, signal);
tunnelProcess = null;
tunnelPid = null;
await clearPidFile();
await writeStateFile({
...currentState,
pid: null,
publicUrl: null,
apiUrl: null,
status: lastError ? "error" : "stopped",
lastError,
});
}
async function killPid(pid: number) {
process.kill(pid, "SIGTERM");
const start = Date.now();
while (Date.now() - start < STOP_TIMEOUT_MS) {
if (!isProcessAlive(pid)) return;
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (isProcessAlive(pid)) {
process.kill(pid, "SIGKILL");
}
}
async function stopExistingTunnel() {
if (tunnelProcess && tunnelPid && !tunnelProcess.killed) {
const pid = tunnelPid;
tunnelProcess.kill("SIGTERM");
await killPid(pid);
return;
}
const state = await readStateFile();
if (!isStateOwnedByCurrentProcess(state)) {
await clearPidFile();
return;
}
const pid = await readPidFile();
if (pid && isProcessAlive(pid)) {
await killPid(pid);
}
}
export async function getCloudflaredTunnelStatus(): Promise<CloudflaredTunnelStatus> {
const state = await readStateFile();
const resolved = await resolveBinary();
const pidFromState =
tunnelPid || (isStateOwnedByCurrentProcess(state) ? state.pid || (await readPidFile()) : null);
const running = isProcessAlive(pidFromState);
const needsColdStartReset =
!running && !isStateOwnedByCurrentProcess(state) && hasTransientRuntimeState(state);
const effectiveState = needsColdStartReset
? buildStoppedState(state, !!resolved.binaryPath)
: state;
if (needsColdStartReset) {
await writeStateFile(effectiveState);
}
const publicUrl = running ? effectiveState.publicUrl || null : null;
const phase =
!getCloudflaredAssetSpec() && !resolved.binaryPath
? "unsupported"
: running
? publicUrl
? "running"
: "starting"
: resolved.binaryPath
? effectiveState.lastError
? "error"
: "stopped"
: "not_installed";
if (!running && state.pid) {
await clearPidFile();
}
return {
supported: !!(getCloudflaredAssetSpec() || resolved.binaryPath),
installed: !!resolved.binaryPath,
managedInstall: resolved.managed,
installSource: resolved.source,
binaryPath: resolved.binaryPath,
running,
pid: running ? pidFromState : null,
publicUrl,
apiUrl: publicUrl ? getTunnelApiUrl(publicUrl) : null,
targetUrl: effectiveState.targetUrl || getLocalTargetUrl(),
phase,
lastError: running ? null : effectiveState.lastError || null,
logPath: getLogFilePath(),
};
}
export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus> {
const current = await getCloudflaredTunnelStatus();
if (current.running) return current;
if (startPromise) return startPromise;
startPromise = (async () => {
const spec = getCloudflaredAssetSpec();
if (!spec && !(await resolveBinary()).binaryPath) {
throw new Error(
`Unsupported platform for cloudflared tunnel: ${process.platform}/${process.arch}`
);
}
const binary = await ensureBinary();
const targetUrl = getLocalTargetUrl();
await stopExistingTunnel();
await ensureTunnelDir();
await ensureTunnelRuntimeDirs();
await fs.writeFile(getLogFilePath(), "", "utf8");
await writeStateFile({
binaryPath: binary.binaryPath,
installSource: binary.source,
ownerPid: process.pid,
pid: null,
publicUrl: null,
apiUrl: null,
targetUrl,
status: "starting",
lastError: null,
startedAt: new Date().toISOString(),
});
const child = spawn(binary.binaryPath as string, getCloudflaredStartArgs(targetUrl), {
stdio: ["ignore", "pipe", "pipe"],
env: buildCloudflaredChildEnv(),
});
tunnelProcess = child;
tunnelPid = child.pid ?? null;
if (!child.pid) {
throw new Error("cloudflared failed to start");
}
await writePidFile(child.pid);
await updateStateFile({ pid: child.pid, status: "starting" });
const ready = await new Promise<CloudflaredTunnelStatus>((resolve, reject) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const settle = (handler: () => void) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
handler();
};
const handleOutput = async (source: "stdout" | "stderr", chunk: Buffer) => {
const text = chunk.toString("utf8").trim();
if (!text) return;
await appendTunnelLog(source, text);
const errorMessage = source === "stderr" ? extractCloudflaredErrorMessage(text) : null;
if (errorMessage) {
await updateStateFile({
ownerPid: process.pid,
pid: child.pid,
status: "error",
lastError: errorMessage,
});
}
const url = extractTryCloudflareUrl(text);
if (!url) return;
const apiUrl = getTunnelApiUrl(url);
await updateStateFile({
ownerPid: process.pid,
pid: child.pid,
publicUrl: url,
apiUrl,
status: "running",
lastError: null,
});
const status = await getCloudflaredTunnelStatus();
settle(() => resolve(status));
};
child.stdout.on("data", (chunk: Buffer) => {
void handleOutput("stdout", chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
void handleOutput("stderr", chunk);
});
child.once("exit", (code, signal) => {
void finalizeProcessExit(code, signal);
settle(() =>
reject(
new Error(
`cloudflared exited before tunnel URL was ready (${code ?? "signal"}${signal ? `/${signal}` : ""})`
)
)
);
});
timeout = setTimeout(async () => {
await stopExistingTunnel();
settle(() => reject(new Error("Timed out while waiting for Cloudflare tunnel URL")));
}, START_TIMEOUT_MS);
});
return ready;
})();
try {
return await startPromise;
} catch (error) {
const currentState = await readStateFile();
const message = isSpecificCloudflaredError(currentState.lastError)
? currentState.lastError
: error instanceof Error
? error.message
: "Failed to start cloudflared tunnel";
await updateStateFile({
ownerPid: process.pid,
status: "error",
lastError: message,
});
throw new Error(message);
} finally {
startPromise = null;
}
}
export async function stopCloudflaredTunnel() {
await stopExistingTunnel();
const current = await readStateFile();
await writeStateFile({
...buildStoppedState(current, !!(await resolveBinary()).binaryPath),
ownerPid: null,
});
tunnelProcess = null;
tunnelPid = null;
await clearPidFile();
return getCloudflaredTunnelStatus();
}
+92
View File
@@ -0,0 +1,92 @@
/**
* combos/autoPromote.ts — pure reorder helper for the "auto-promote successful
* combo model" feature.
*
* When the `comboAutoPromoteEnabled` setting is on and a combo model responds
* successfully, the winning model is moved to position #1 of the persisted
* combo so future requests try it first.
*
* OmniRoute stores `combo.models` as an array of `ComboStep` objects
* (`{ kind: "model", model, ... }`), unlike the upstream project which stores
* plain model strings. This helper accepts both shapes and reorders in place
* without mutating the input, returning `null` when no change is required
* (winner already first, winner absent, or empty list).
*/
type StepLike = { kind?: unknown; model?: unknown } | string;
/** Extract the model id from a step entry (object or bare string). */
export function comboStepModelId(step: unknown): string | null {
if (typeof step === "string") return step.trim().length > 0 ? step : null;
if (step && typeof step === "object") {
const model = (step as { model?: unknown }).model;
if (typeof model === "string" && model.trim().length > 0) return model;
}
return null;
}
/**
* Return a reordered copy of `models` with the entry matching `winningModel`
* moved to the front, or `null` if no reordering is needed/possible.
*
* Pure: never mutates the input array or its entries.
*/
export function promoteModelToFront<T extends StepLike>(
models: readonly T[] | null | undefined,
winningModel: string | null | undefined
): T[] | null {
if (!Array.isArray(models) || models.length === 0) return null;
if (typeof winningModel !== "string" || winningModel.length === 0) return null;
const matchIndex = models.findIndex((step) => comboStepModelId(step) === winningModel);
// Winner not in the combo (e.g. global fallback) or already first → no-op.
if (matchIndex <= 0) return null;
const winner = models[matchIndex];
const rest = models.filter((_, index) => index !== matchIndex);
return [winner, ...rest];
}
interface PromoteComboDeps {
updateCombo: (id: string, data: { models: unknown[] }) => Promise<unknown>;
info?: (tag: string, msg: string) => void;
warn?: (tag: string, msg: string) => void;
}
/**
* Persist the auto-promotion of a successful combo model to position #1.
*
* Opt-in via the `comboAutoPromoteEnabled` setting. Best-effort: a DB failure is
* logged and swallowed so it never affects the already-successful response.
* No-op when the flag is off, the combo has no id, or the model is already first
* / absent. `updateCombo` is injected so this stays unit-testable without a DB.
*/
export async function promoteSuccessfulComboModel(
combo: { id?: unknown; name?: unknown; models?: unknown } | null | undefined,
winningModel: string | null | undefined,
settings: Record<string, unknown> | null | undefined,
deps: PromoteComboDeps
): Promise<boolean> {
if (!combo || !settings || !settings.comboAutoPromoteEnabled) return false;
const comboId = typeof combo.id === "string" ? combo.id : null;
if (!comboId) return false;
const reordered = promoteModelToFront(
Array.isArray(combo.models) ? (combo.models as unknown[]) : null,
winningModel
);
if (!reordered) return false;
const label = typeof combo.name === "string" ? combo.name : comboId;
try {
await deps.updateCombo(comboId, { models: reordered });
deps.info?.("COMBO", `Model "${winningModel}" succeeded — promoted to #1 in combo "${label}"`);
return true;
} catch (dbErr: any) {
deps.warn?.(
"COMBO",
`Failed to promote model "${winningModel}" in combo "${label}": ${
dbErr?.message || "unknown error"
}`
);
return false;
}
}
+242
View File
@@ -0,0 +1,242 @@
import type { ComboModelStep } from "@/lib/combos/steps";
type JsonRecord = Record<string, unknown>;
export const COMBO_BUILDER_AUTO_CONNECTION = "__auto__";
export const COMBO_BUILDER_STAGES = [
"basics",
"steps",
"strategy",
"intelligent",
"review",
] as const;
export type ComboBuilderStage = (typeof COMBO_BUILDER_STAGES)[number];
export type ComboBuilderStageOptions = {
strategy?: string | null;
};
export function isIntelligentBuilderStrategy(strategy: unknown): boolean {
return strategy === "auto" || strategy === "lkgp";
}
export function getComboBuilderStages(options: ComboBuilderStageOptions = {}): ComboBuilderStage[] {
if (isIntelligentBuilderStrategy(options.strategy)) {
return [...COMBO_BUILDER_STAGES];
}
return COMBO_BUILDER_STAGES.filter((stage) => stage !== "intelligent");
}
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toTrimmedString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function parseQualifiedModel(
value: unknown
): { providerId: string; modelId: string } | null {
const qualifiedModel = toTrimmedString(value);
if (!qualifiedModel) return null;
const firstSlashIndex = qualifiedModel.indexOf("/");
if (firstSlashIndex <= 0 || firstSlashIndex >= qualifiedModel.length - 1) return null;
return {
providerId: qualifiedModel.slice(0, firstSlashIndex),
modelId: qualifiedModel.slice(firstSlashIndex + 1),
};
}
export function buildPrecisionComboModelStep({
providerId,
modelId,
connectionId = null,
connectionLabel,
allowedConnectionIds = null,
weight = 0,
}: {
providerId: string;
modelId: string;
connectionId?: string | null;
connectionLabel?: string | null;
/** #3266: account allowlist scoping round-robin to a subset of connections. */
allowedConnectionIds?: string[] | null;
weight?: number;
}): ComboModelStep {
const normalizedProviderId = toTrimmedString(providerId) || "provider";
const normalizedModelId = toTrimmedString(modelId) || "model";
const normalizedConnectionId = toTrimmedString(connectionId);
const normalizedConnectionLabel = toTrimmedString(connectionLabel);
// A pinned single connection wins over an allowlist, so only carry the allowlist
// when the step is auto-selecting (no forced connectionId).
const normalizedAllowed =
!normalizedConnectionId && Array.isArray(allowedConnectionIds)
? Array.from(
new Set(
allowedConnectionIds.map((id) => toTrimmedString(id)).filter((id): id is string => !!id)
)
)
: [];
return {
kind: "model",
providerId: normalizedProviderId,
model: `${normalizedProviderId}/${normalizedModelId}`,
...(normalizedConnectionId ? { connectionId: normalizedConnectionId } : {}),
...(normalizedConnectionLabel ? { label: normalizedConnectionLabel } : {}),
...(normalizedAllowed.length > 0 ? { allowedConnectionIds: normalizedAllowed } : {}),
weight: Number.isFinite(weight) ? Math.max(0, Math.min(100, Number(weight))) : 0,
};
}
type ComboBuilderProviderIdentity = {
providerId?: unknown;
alias?: unknown;
prefix?: unknown;
};
export function resolveComboBuilderProviderId(
providerIdOrAlias: unknown,
providers: ComboBuilderProviderIdentity[] = []
): string | null {
const normalizedProviderId = toTrimmedString(providerIdOrAlias);
if (!normalizedProviderId) return null;
const matchedProvider = providers.find((provider) => {
const providerId = toTrimmedString(provider.providerId);
const alias = toTrimmedString(provider.alias);
const prefix = toTrimmedString(provider.prefix);
return (
providerId === normalizedProviderId ||
alias === normalizedProviderId ||
prefix === normalizedProviderId
);
});
return toTrimmedString(matchedProvider?.providerId) || null;
}
export function buildManualComboModelStep({
value,
providers = [],
weight = 0,
}: {
value: unknown;
providers?: ComboBuilderProviderIdentity[];
weight?: number;
}): ComboModelStep | null {
const parsed = parseQualifiedModel(value);
if (!parsed) return null;
const providerId = resolveComboBuilderProviderId(parsed.providerId, providers);
if (!providerId) return null;
return buildPrecisionComboModelStep({
providerId,
modelId: parsed.modelId,
weight,
});
}
export function getExactModelStepSignature(entry: unknown): string | null {
if (!isRecord(entry) || entry.kind === "combo-ref") return null;
const modelValue = toTrimmedString(entry.model);
const parsed = parseQualifiedModel(modelValue);
if (!parsed) return null;
const normalizedProviderId = toTrimmedString(entry.providerId) || parsed.providerId;
const normalizedConnectionId =
toTrimmedString(entry.connectionId) || COMBO_BUILDER_AUTO_CONNECTION;
return `model:${normalizedProviderId}:${parsed.modelId}:${normalizedConnectionId}`;
}
export function hasExactModelStepDuplicate(entries: unknown[], candidate: unknown): boolean {
const candidateSignature = getExactModelStepSignature(candidate);
if (!candidateSignature) return false;
return entries.some((entry) => getExactModelStepSignature(entry) === candidateSignature);
}
export function findNextSuggestedConnectionId(
entries: unknown[],
providerId: string,
modelId: string,
connections: Array<{ id?: string | null }> = []
): string {
for (const connection of connections) {
const connectionId = toTrimmedString(connection?.id);
if (!connectionId) continue;
const step = buildPrecisionComboModelStep({
providerId,
modelId,
connectionId,
});
if (!hasExactModelStepDuplicate(entries, step)) {
return connectionId;
}
}
return COMBO_BUILDER_AUTO_CONNECTION;
}
export function getComboBuilderStageChecks({
name,
nameError,
modelsCount,
hasInvalidWeightedTotal,
hasCostOptimizedWithoutPricing,
}: {
name: string;
nameError?: string | null;
modelsCount: number;
hasInvalidWeightedTotal?: boolean;
hasCostOptimizedWithoutPricing?: boolean;
}) {
return {
basics: Boolean(toTrimmedString(name)) && !toTrimmedString(nameError),
steps: modelsCount > 0,
strategy: !Boolean(hasInvalidWeightedTotal) && !Boolean(hasCostOptimizedWithoutPricing),
review: false,
};
}
export function canAccessComboBuilderStage(
stage: ComboBuilderStage,
checks: ReturnType<typeof getComboBuilderStageChecks>,
options: ComboBuilderStageOptions = {}
): boolean {
const availableStages = getComboBuilderStages(options);
if (!availableStages.includes(stage)) return false;
if (stage === "basics") return true;
if (stage === "steps") return checks.basics;
if (stage === "strategy") return checks.basics && checks.steps;
if (stage === "intelligent") return checks.basics && checks.steps && checks.strategy;
if (stage === "review") return checks.basics && checks.steps;
return false;
}
export function getNextComboBuilderStage(
stage: ComboBuilderStage,
options: ComboBuilderStageOptions = {}
): ComboBuilderStage {
const stages = getComboBuilderStages(options);
const stageIndex = stages.indexOf(stage);
if (stageIndex === -1 || stageIndex >= stages.length - 1) {
return "review";
}
return stages[stageIndex + 1];
}
export function getPreviousComboBuilderStage(
stage: ComboBuilderStage,
options: ComboBuilderStageOptions = {}
): ComboBuilderStage {
const stages = getComboBuilderStages(options);
const stageIndex = stages.indexOf(stage);
if (stageIndex <= 0) return "basics";
return stages[stageIndex - 1];
}
+681
View File
@@ -0,0 +1,681 @@
import {
getAllCustomModels,
getAllSyncedAvailableModels,
getCombos,
getModelIsHidden,
getProviderConnections,
getProviderNodes,
getSettings,
} from "@/lib/localDb";
import { getAccountDisplayName, getProviderDisplayName } from "@/lib/display/names";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
import { getSyncedCapabilities } from "@/lib/modelsDevSync";
import { getModelsByProviderId } from "@/shared/constants/models";
import {
AI_PROVIDERS,
NOAUTH_PROVIDERS,
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry.ts";
type JsonRecord = Record<string, unknown>;
type BuilderModelSource = "imported" | "system" | "custom" | "fallback";
type BuilderConnectionStatus = "active" | "inactive" | "rate-limited" | "error";
type ProviderVisual = { icon: string; color: string; source: "system" | "provider-node" };
type CustomModelLike = {
id?: string;
name?: string;
source?: string;
apiFormat?: string;
supportedEndpoints?: string[];
inputTokenLimit?: number;
outputTokenLimit?: number;
supportsThinking?: boolean;
isHidden?: boolean;
};
type SyncedModelLike = {
id?: string;
name?: string;
source?: string;
supportedEndpoints?: string[];
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
};
type ProviderConnectionLike = {
id?: string;
provider?: string;
authType?: string;
name?: string;
displayName?: string;
email?: string;
priority?: number;
isActive?: boolean;
defaultModel?: string;
rateLimitedUntil?: number | null;
lastError?: string | null;
lastTested?: string | null;
updatedAt?: string | null;
testStatus?: string | null;
providerSpecificData?: Record<string, unknown> | null;
};
type ProviderNodeLike = {
id?: string;
type?: string;
name?: string;
prefix?: string;
};
export interface ComboBuilderModelOption {
id: string;
qualifiedModel: string;
name: string;
source: BuilderModelSource;
sources: BuilderModelSource[];
supportedEndpoints?: string[];
apiFormat?: string;
contextLength?: number;
outputTokenLimit?: number;
supportsThinking?: boolean;
}
export interface ComboBuilderConnectionOption {
id: string;
label: string;
type: string;
status: BuilderConnectionStatus;
priority: number;
isActive: boolean;
defaultModel?: string | null;
rateLimitedUntil?: number | null;
lastError?: string | null;
lastTested?: string | null;
}
export interface ComboBuilderProviderOption {
providerId: string;
providerType: string;
displayName: string;
alias: string;
prefix?: string | null;
icon: string;
color: string;
source: "system" | "provider-node";
acceptsArbitraryModel: boolean;
connectionCount: number;
activeConnectionCount: number;
modelCount: number;
connections: ComboBuilderConnectionOption[];
models: ComboBuilderModelOption[];
}
export interface ComboBuilderComboRefOption {
id: string;
name: string;
strategy: string;
stepCount: number;
version: number;
sortOrder?: number;
}
export interface ComboBuilderOptionsPayload {
schemaVersion: number;
generatedAt: string;
providers: ComboBuilderProviderOption[];
comboRefs: ComboBuilderComboRefOption[];
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function toNumberOrNull(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const normalized = value
.map((item) => toStringOrNull(item))
.filter((item): item is string => Boolean(item));
return normalized.length > 0 ? normalized : undefined;
}
function isChatCapable(supportedEndpoints: string[] | undefined): boolean {
if (!supportedEndpoints || supportedEndpoints.length === 0) return true;
return supportedEndpoints.includes("chat");
}
function getSourcePriority(source: BuilderModelSource): number {
switch (source) {
case "imported":
return 0;
case "system":
return 1;
case "custom":
return 2;
case "fallback":
return 3;
default:
return 99;
}
}
function getCompatibleProviderVisual(providerNodeType: string | null): ProviderVisual {
if (providerNodeType === "openai-compatible") {
return { icon: "api", color: "#10A37F", source: "provider-node" };
}
if (providerNodeType === "anthropic-compatible") {
return { icon: "api", color: "#D97757", source: "provider-node" };
}
if (providerNodeType === "anthropic-compatible-cc") {
return { icon: "smart_toy", color: "#D97757", source: "provider-node" };
}
return { icon: "api", color: "#6B7280", source: "provider-node" };
}
function getProviderVisual(
providerId: string,
providerNode: ProviderNodeLike | null
): ProviderVisual & { alias: string; providerType: string } {
const providerEntry = AI_PROVIDERS[providerId];
if (providerEntry) {
return {
alias: providerEntry.alias || providerEntry.id,
providerType: providerEntry.id,
icon: providerEntry.icon || "hub",
color: providerEntry.color || "#6B7280",
source: "system",
};
}
const providerNodeType = toStringOrNull(providerNode?.type);
const compatibleVisual = getCompatibleProviderVisual(providerNodeType);
return {
alias: toStringOrNull(providerNode?.prefix) || providerId,
providerType: providerNodeType || providerId,
...compatibleVisual,
};
}
function deriveConnectionStatus(connection: ProviderConnectionLike): BuilderConnectionStatus {
if (connection.isActive === false) return "inactive";
const rateLimitedUntil = toNumberOrNull(connection.rateLimitedUntil);
if (typeof rateLimitedUntil === "number" && rateLimitedUntil > Date.now()) {
return "rate-limited";
}
if (typeof connection.testStatus === "string" && /error|fail/i.test(connection.testStatus)) {
return "error";
}
return "active";
}
/**
* Expand a list of raw DB connection rows into `ComboBuilderConnectionOption` items.
*
* No-auth account providers (opencode, mimocode) pack multiple "accounts" as UUIDs in
* `providerSpecificData.fingerprints` of a single DB row (#6087). This helper inflates
* them: each fingerprint becomes a separate option labeled "Account N" with an id of
* `${rowId}|fp|${fingerprint}` so the combo step can pin a specific account.
* Rows without fingerprints are converted 1:1 via buildConnectionOption as before.
*/
export function expandConnectionOptions(
connections: ProviderConnectionLike[]
): ComboBuilderConnectionOption[] {
const result: ComboBuilderConnectionOption[] = [];
for (const connection of connections) {
const fingerprints = Array.isArray(connection.providerSpecificData?.fingerprints)
? (connection.providerSpecificData.fingerprints as unknown[]).filter(
(fp): fp is string => typeof fp === "string" && fp.length > 0
)
: [];
if (fingerprints.length > 0) {
const baseStatus = deriveConnectionStatus(connection);
const basePriority = typeof connection.priority === "number" ? connection.priority : 0;
for (let i = 0; i < fingerprints.length; i++) {
result.push({
id: `${toStringOrNull(connection.id) || ""}|fp|${fingerprints[i]}`,
label: `Account ${i + 1}`,
type: toStringOrNull(connection.authType) || "unknown",
status: baseStatus,
priority: basePriority,
isActive: connection.isActive !== false,
defaultModel: toStringOrNull(connection.defaultModel),
rateLimitedUntil: toNumberOrNull(connection.rateLimitedUntil),
lastError: toStringOrNull(connection.lastError),
lastTested: toStringOrNull(connection.lastTested),
});
}
} else {
const opt = buildConnectionOption(connection);
if (opt) result.push(opt);
}
}
return result;
}
function buildConnectionOption(
connection: ProviderConnectionLike
): ComboBuilderConnectionOption | null {
const id = toStringOrNull(connection.id);
if (!id) return null;
return {
id,
label: getAccountDisplayName(connection),
type: toStringOrNull(connection.authType) || "unknown",
status: deriveConnectionStatus(connection),
priority: typeof connection.priority === "number" ? connection.priority : 0,
isActive: connection.isActive !== false,
defaultModel: toStringOrNull(connection.defaultModel),
rateLimitedUntil: toNumberOrNull(connection.rateLimitedUntil),
lastError: toStringOrNull(connection.lastError),
lastTested: toStringOrNull(connection.lastTested),
};
}
function addModelOption(
modelMap: Map<string, ComboBuilderModelOption>,
providerId: string,
input: {
id: string | null;
name?: string | null;
source: BuilderModelSource;
supportedEndpoints?: string[];
apiFormat?: string | null;
contextLength?: number | null;
outputTokenLimit?: number | null;
supportsThinking?: boolean;
}
) {
const modelId = toStringOrNull(input.id);
if (!modelId) return;
if (getModelIsHidden(providerId, modelId)) return;
if (!isChatCapable(input.supportedEndpoints)) return;
const nextSourcePriority = getSourcePriority(input.source);
const existing = modelMap.get(modelId);
if (!existing) {
modelMap.set(modelId, {
id: modelId,
qualifiedModel: `${providerId}/${modelId}`,
name: toStringOrNull(input.name) || modelId,
source: input.source,
sources: [input.source],
...(input.supportedEndpoints && input.supportedEndpoints.length > 0
? { supportedEndpoints: input.supportedEndpoints }
: {}),
...(toStringOrNull(input.apiFormat) ? { apiFormat: input.apiFormat || undefined } : {}),
...(typeof input.contextLength === "number" ? { contextLength: input.contextLength } : {}),
...(typeof input.outputTokenLimit === "number"
? { outputTokenLimit: input.outputTokenLimit }
: {}),
...(typeof input.supportsThinking === "boolean"
? { supportsThinking: input.supportsThinking }
: {}),
});
return;
}
const existingPriority = getSourcePriority(existing.source);
const mergedSources = new Set<BuilderModelSource>([...existing.sources, input.source]);
if (nextSourcePriority < existingPriority) {
existing.source = input.source;
}
if (!existing.name || existing.name === existing.id) {
existing.name = toStringOrNull(input.name) || existing.name;
}
if (!existing.supportedEndpoints && input.supportedEndpoints?.length) {
existing.supportedEndpoints = input.supportedEndpoints;
}
if (!existing.apiFormat && toStringOrNull(input.apiFormat)) {
existing.apiFormat = input.apiFormat || undefined;
}
if (existing.contextLength == null && typeof input.contextLength === "number") {
existing.contextLength = input.contextLength;
}
if (existing.outputTokenLimit == null && typeof input.outputTokenLimit === "number") {
existing.outputTokenLimit = input.outputTokenLimit;
}
if (existing.supportsThinking == null && typeof input.supportsThinking === "boolean") {
existing.supportsThinking = input.supportsThinking;
}
existing.sources = Array.from(mergedSources).sort(
(left, right) => getSourcePriority(left) - getSourcePriority(right)
);
}
function buildModelOptions(
providerId: string,
builtInModels: RegistryModel[],
syncedModels: SyncedModelLike[],
customModels: CustomModelLike[]
): Map<string, ComboBuilderModelOption> {
const modelMap = new Map<string, ComboBuilderModelOption>();
const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels);
for (const model of syncedModels) {
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: toStringOrNull(model.id),
});
addModelOption(modelMap, providerId, {
id: toStringOrNull(model.id),
name: toStringOrNull(model.name),
source: "imported",
supportedEndpoints: toStringArray(model.supportedEndpoints),
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
supportsThinking:
typeof model.supportsThinking === "boolean"
? model.supportsThinking
: (resolved.supportsThinking ?? undefined),
});
}
for (const model of builtInModels) {
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: toStringOrNull(model.id),
});
addModelOption(modelMap, providerId, {
id: toStringOrNull(model.id),
name: toStringOrNull(model.name),
source: "system",
contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow,
outputTokenLimit: resolved.maxOutputTokens,
supportsThinking: resolved.supportsThinking ?? undefined,
});
}
for (const model of customModels) {
if (model.isHidden === true) continue;
const source = ["api-sync", "auto-sync", "imported"].includes(
toStringOrNull(model.source)?.toLowerCase() || ""
)
? "imported"
: ("custom" as BuilderModelSource);
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: toStringOrNull(model.id),
});
addModelOption(modelMap, providerId, {
id: toStringOrNull(model.id),
name: toStringOrNull(model.name),
source,
supportedEndpoints: toStringArray(model.supportedEndpoints),
apiFormat: toStringOrNull(model.apiFormat),
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
supportsThinking:
typeof model.supportsThinking === "boolean"
? model.supportsThinking
: (resolved.supportsThinking ?? undefined),
});
}
if (Array.isArray(fallbackModels)) {
for (const model of fallbackModels) {
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: toStringOrNull(model.id),
});
addModelOption(modelMap, providerId, {
id: toStringOrNull(model.id),
name: toStringOrNull(model.name),
source: "fallback",
contextLength:
typeof (model as { contextLength?: number }).contextLength === "number"
? (model as { contextLength?: number }).contextLength || null
: resolved.contextWindow,
outputTokenLimit: resolved.maxOutputTokens,
supportsThinking: resolved.supportsThinking ?? undefined,
});
}
}
return modelMap;
}
function compareConnections(
left: ComboBuilderConnectionOption,
right: ComboBuilderConnectionOption
): number {
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
if (left.priority !== right.priority) return left.priority - right.priority;
return left.label.localeCompare(right.label, undefined, { sensitivity: "base" });
}
function compareModels(left: ComboBuilderModelOption, right: ComboBuilderModelOption): number {
const sourceDelta = getSourcePriority(left.source) - getSourcePriority(right.source);
if (sourceDelta !== 0) return sourceDelta;
const nameDelta = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
if (nameDelta !== 0) return nameDelta;
return left.id.localeCompare(right.id, undefined, { sensitivity: "base" });
}
function compareProviders(
left: ComboBuilderProviderOption,
right: ComboBuilderProviderOption
): number {
if (left.activeConnectionCount !== right.activeConnectionCount) {
return right.activeConnectionCount - left.activeConnectionCount;
}
return left.displayName.localeCompare(right.displayName, undefined, { sensitivity: "base" });
}
function normalizeCustomModels(raw: unknown): CustomModelLike[] {
return Array.isArray(raw)
? raw.filter(
(model): model is CustomModelLike =>
Boolean(model) && typeof model === "object" && !Array.isArray(model)
)
: [];
}
function normalizeSyncedModels(raw: unknown): SyncedModelLike[] {
return Array.isArray(raw)
? raw.filter(
(model): model is SyncedModelLike =>
Boolean(model) && typeof model === "object" && !Array.isArray(model)
)
: [];
}
export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPayload> {
getSyncedCapabilities();
const [connections, providerNodes, customModelsMap, syncedModelsMap, combos, settings] =
await Promise.all([
getProviderConnections(),
getProviderNodes(),
getAllCustomModels(),
getAllSyncedAvailableModels(),
getCombos(),
getSettings().catch(() => ({}) as Record<string, unknown>),
]);
const blockedProviders = new Set(
Array.isArray((settings as Record<string, unknown>).blockedProviders)
? ((settings as Record<string, unknown>).blockedProviders as string[])
: []
);
const providerNodeMap = new Map<string, ProviderNodeLike>();
for (const providerNode of providerNodes as ProviderNodeLike[]) {
const providerId = toStringOrNull(providerNode.id);
if (!providerId) continue;
providerNodeMap.set(providerId, providerNode);
}
const connectionsByProvider = new Map<string, ProviderConnectionLike[]>();
for (const connection of connections as ProviderConnectionLike[]) {
const providerId = toStringOrNull(connection.provider);
if (!providerId) continue;
const list = connectionsByProvider.get(providerId) || [];
list.push(connection);
connectionsByProvider.set(providerId, list);
}
const providers: ComboBuilderProviderOption[] = [];
for (const [providerId, providerConnections] of connectionsByProvider) {
const providerNode = providerNodeMap.get(providerId) || null;
const providerVisual = getProviderVisual(providerId, providerNode);
const builtInModels = getModelsByProviderId(providerId);
const syncedModels = normalizeSyncedModels(
(syncedModelsMap as Record<string, unknown>)[providerId]
);
const customModels = normalizeCustomModels(
(customModelsMap as Record<string, unknown>)[providerId]
);
const acceptsArbitraryModel =
Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) ||
isOpenAICompatibleProvider(providerId) ||
isAnthropicCompatibleProvider(providerId) ||
isClaudeCodeCompatibleProvider(providerId);
const modelMap = buildModelOptions(
providerId,
builtInModels as RegistryModel[],
syncedModels,
customModels
);
const normalizedConnections = expandConnectionOptions(providerConnections).sort(
compareConnections
);
const activeConnectionCount = normalizedConnections.filter(
(connection) => connection.isActive
).length;
const displayName = (providerEntryName(providerId) ||
getProviderDisplayName(providerId, providerNode) ||
providerId) as string;
providers.push({
providerId,
providerType: providerVisual.providerType,
displayName,
alias: providerVisual.alias,
prefix: toStringOrNull(providerNode?.prefix),
icon: providerVisual.icon,
color: providerVisual.color,
source: providerVisual.source,
acceptsArbitraryModel,
connectionCount: normalizedConnections.length,
activeConnectionCount,
modelCount: modelMap.size,
connections: normalizedConnections,
models: Array.from(modelMap.values()).sort(compareModels),
});
}
// No-auth providers have no rows in provider_connections, so they are never included in the
// connectionsByProvider loop above. Add them unless the provider is explicitly blocked.
for (const noAuthProvider of Object.values(NOAUTH_PROVIDERS)) {
const providerId = noAuthProvider.id;
if (
blockedProviders.has(providerId) ||
(typeof noAuthProvider.alias === "string" && blockedProviders.has(noAuthProvider.alias))
)
continue;
// Skip if already covered (defensive: shouldn't happen for true no-auth providers)
if (connectionsByProvider.has(providerId)) continue;
const providerVisual = getProviderVisual(providerId, null);
const builtInModels = getModelsByProviderId(providerId);
const syncedModels = normalizeSyncedModels(
(syncedModelsMap as Record<string, unknown>)[providerId]
);
const customModels = normalizeCustomModels(
(customModelsMap as Record<string, unknown>)[providerId]
);
const acceptsArbitraryModel =
Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) ||
isOpenAICompatibleProvider(providerId) ||
isAnthropicCompatibleProvider(providerId) ||
isClaudeCodeCompatibleProvider(providerId);
const modelMap = buildModelOptions(
providerId,
builtInModels as RegistryModel[],
syncedModels,
customModels
);
// #2901: no-auth providers must route under their alias (e.g. "oc"), not
// their id — "opencode/<model>" misroutes to the opencode-zen api-key tier
// (manual ALIAS_TO_PROVIDER_ID override), while "oc/<model>" resolves to the
// no-auth "opencode" provider. Rewrite qualifiedModel to the alias prefix.
const routingPrefix = noAuthProvider.alias || providerId;
if (routingPrefix !== providerId) {
for (const opt of modelMap.values()) {
opt.qualifiedModel = `${routingPrefix}/${opt.id}`;
}
}
const displayName = (providerEntryName(providerId) ||
getProviderDisplayName(providerId, null) ||
providerId) as string;
providers.push({
providerId,
providerType: providerVisual.providerType,
displayName,
alias: providerVisual.alias,
prefix: null,
icon: providerVisual.icon,
color: providerVisual.color,
source: providerVisual.source,
acceptsArbitraryModel,
connectionCount: 0,
activeConnectionCount: 0,
modelCount: modelMap.size,
connections: [],
models: Array.from(modelMap.values()).sort(compareModels),
});
}
const comboRefs = (combos as JsonRecord[])
.filter((combo) => combo.isHidden !== true && combo.isActive !== false)
.map((combo) => ({
id: toStringOrNull(combo.id) || toStringOrNull(combo.name) || "combo",
name: toStringOrNull(combo.name) || "combo",
strategy: toStringOrNull(combo.strategy) || "priority",
stepCount: Array.isArray(combo.models) ? combo.models.length : 0,
version: typeof combo.version === "number" ? combo.version : 2,
...(typeof combo.sortOrder === "number" ? { sortOrder: combo.sortOrder } : {}),
}))
.sort((left, right) => {
const leftSort =
typeof left.sortOrder === "number" ? left.sortOrder : Number.MAX_SAFE_INTEGER;
const rightSort =
typeof right.sortOrder === "number" ? right.sortOrder : Number.MAX_SAFE_INTEGER;
if (leftSort !== rightSort) return leftSort - rightSort;
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
return {
schemaVersion: 2,
generatedAt: new Date().toISOString(),
providers: providers.sort(compareProviders),
comboRefs,
};
}
function providerEntryName(providerId: string): string | null {
const providerEntry = AI_PROVIDERS[providerId] as { name?: string } | undefined;
return toStringOrNull(providerEntry?.name);
}
+200
View File
@@ -0,0 +1,200 @@
import { normalizeComboModels } from "@/lib/combos/steps";
type JsonRecord = Record<string, unknown>;
type ValidationErrorDetail = {
field: string;
message: string;
};
type CompositeTierValidationFailure = {
success: false;
error: {
message: string;
details: ValidationErrorDetail[];
};
};
type CompositeTierValidationSuccess = {
success: true;
};
export type CompositeTierValidationResult =
| CompositeTierValidationFailure
| CompositeTierValidationSuccess;
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toTrimmedString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function createFailure(details: ValidationErrorDetail[]): CompositeTierValidationFailure {
return {
success: false,
error: {
message: "Invalid composite tiers",
details,
},
};
}
export function validateCompositeTiersConfig(combo: {
name?: unknown;
models?: unknown;
config?: unknown;
}): CompositeTierValidationResult {
if (!isRecord(combo.config)) {
return { success: true };
}
const compositeTiers = combo.config.compositeTiers;
if (compositeTiers === undefined || compositeTiers === null) {
return { success: true };
}
if (!isRecord(compositeTiers)) {
return createFailure([
{
field: "config.compositeTiers",
message: "compositeTiers must be an object",
},
]);
}
const defaultTier = toTrimmedString(compositeTiers.defaultTier);
const tiers = isRecord(compositeTiers.tiers) ? compositeTiers.tiers : null;
const details: ValidationErrorDetail[] = [];
if (!defaultTier) {
details.push({
field: "config.compositeTiers.defaultTier",
message: "defaultTier is required",
});
}
if (!tiers || Object.keys(tiers).length === 0) {
details.push({
field: "config.compositeTiers.tiers",
message: "tiers must define at least one tier",
});
return createFailure(details);
}
const normalizedSteps = normalizeComboModels(combo.models, {
comboName: toTrimmedString(combo.name),
});
const stepIds = new Set(
normalizedSteps
.map((step) => toTrimmedString(step.id))
.filter((value): value is string => !!value)
);
const tierEntries = new Map<string, { stepId: string; fallbackTier: string | null }>();
const stepIdOwners = new Map<string, string>();
for (const [rawTierName, rawTierValue] of Object.entries(tiers)) {
const tierName = toTrimmedString(rawTierName);
const fieldBase = `config.compositeTiers.tiers.${rawTierName}`;
if (!tierName) {
details.push({
field: fieldBase,
message: "tier name must be a non-empty string",
});
continue;
}
if (!isRecord(rawTierValue)) {
details.push({
field: fieldBase,
message: "tier config must be an object",
});
continue;
}
const stepId = toTrimmedString(rawTierValue.stepId);
const fallbackTier = toTrimmedString(rawTierValue.fallbackTier);
if (!stepId) {
details.push({
field: `${fieldBase}.stepId`,
message: "stepId is required",
});
continue;
}
if (!stepIds.has(stepId)) {
details.push({
field: `${fieldBase}.stepId`,
message: `stepId "${stepId}" does not exist in combo.models`,
});
}
const previousTierForStep = stepIdOwners.get(stepId);
if (previousTierForStep && previousTierForStep !== tierName) {
details.push({
field: `${fieldBase}.stepId`,
message: `stepId "${stepId}" is already assigned to tier "${previousTierForStep}"`,
});
} else {
stepIdOwners.set(stepId, tierName);
}
if (fallbackTier && fallbackTier === tierName) {
details.push({
field: `${fieldBase}.fallbackTier`,
message: "fallbackTier cannot reference the same tier",
});
}
tierEntries.set(tierName, {
stepId,
fallbackTier,
});
}
if (defaultTier && !tierEntries.has(defaultTier)) {
details.push({
field: "config.compositeTiers.defaultTier",
message: `defaultTier "${defaultTier}" does not exist in tiers`,
});
}
for (const [tierName, entry] of tierEntries.entries()) {
if (entry.fallbackTier && !tierEntries.has(entry.fallbackTier)) {
details.push({
field: `config.compositeTiers.tiers.${tierName}.fallbackTier`,
message: `fallbackTier "${entry.fallbackTier}" does not exist in tiers`,
});
}
}
const visitState = new Map<string, "visiting" | "visited">();
function visit(tierName: string, path: string[]) {
const state = visitState.get(tierName);
if (state === "visited") return;
if (state === "visiting") {
details.push({
field: `config.compositeTiers.tiers.${tierName}.fallbackTier`,
message: `fallbackTier cycle detected: ${[...path, tierName].join(" -> ")}`,
});
return;
}
visitState.set(tierName, "visiting");
const fallbackTier = tierEntries.get(tierName)?.fallbackTier;
if (fallbackTier && tierEntries.has(fallbackTier)) {
visit(fallbackTier, [...path, tierName]);
}
visitState.set(tierName, "visited");
}
for (const tierName of tierEntries.keys()) {
visit(tierName, []);
}
return details.length > 0 ? createFailure(details) : { success: true };
}
+271
View File
@@ -0,0 +1,271 @@
import { normalizeComboModels, type ComboStep } from "./steps";
type JsonRecord = Record<string, unknown>;
export type ComboControlCenterHealthState = "healthy" | "warning" | "critical" | "idle";
export interface ComboControlCenterCombo {
id?: string;
name?: string;
strategy?: string | null;
models?: unknown[];
isActive?: boolean | null;
config?: JsonRecord | null;
}
export interface ComboControlCenterMetrics {
totalRequests?: number;
totalSuccesses?: number;
totalFailures?: number;
totalFallbacks?: number;
avgLatencyMs?: number;
successRate?: number;
fallbackRate?: number;
lastUsedAt?: string | null;
}
export interface ComboControlCenterHealth {
performance?: {
avgLatencyMs?: number;
successRate?: number;
totalRequests?: number;
};
quotaHealth?: {
worstRemainingPct?: number;
providers?: Array<{
provider: string;
remainingPct: number;
isExhausted: boolean;
trend: "improving" | "stable" | "declining";
}>;
};
usageSkew?: {
giniCoefficient?: number;
modelDistribution?: Array<{ model: string; requestShare: number; tokenShare: number }>;
};
targetHealth?: ComboControlCenterTargetHealth[];
}
export interface ComboControlCenterTargetHealth {
executionKey?: string;
stepId?: string | null;
model?: string;
provider?: string;
connectionId?: string | null;
label?: string | null;
requests?: number;
successRate?: number;
avgLatencyMs?: number;
lastStatus?: "ok" | "error" | null;
lastUsedAt?: string | null;
quotaRemainingPct?: number | null;
quotaIsExhausted?: boolean | null;
quotaTrend?: "improving" | "stable" | "declining" | null;
quotaScope?: "connection" | "provider" | "none";
}
export interface ComboControlCenterTarget {
id: string;
kind: "model" | "combo-ref";
index: number;
label: string;
model: string;
provider: string | null;
connectionId: string | null;
weight: number;
tags: string[];
health: ComboControlCenterTargetHealth | null;
}
export interface ComboControlCenterSummary {
strategy: string;
isActive: boolean;
targetCount: number;
modelTargetCount: number;
nestedComboCount: number;
providerCount: number;
totalRequests: number;
successRate: number;
avgLatencyMs: number;
fallbackRate: number;
worstQuotaRemainingPct: number | null;
usageSkew: number;
healthState: ComboControlCenterHealthState;
healthReasons: string[];
}
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function toString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function providerFromModel(model: string | null | undefined): string | null {
if (!model) return null;
const slashIndex = model.indexOf("/");
if (slashIndex <= 0) return null;
return model.slice(0, slashIndex);
}
function normalizeSuccessRate(value: unknown): number {
const rate = toNumber(value, 0);
if (rate <= 1) return Math.round(rate * 100);
return Math.round(rate);
}
function getMetricRequests(metrics?: ComboControlCenterMetrics | null): number {
return toNumber(metrics?.totalRequests, 0);
}
function getHealthRequests(health?: ComboControlCenterHealth | null): number {
return toNumber(health?.performance?.totalRequests, 0);
}
function getStepTags(step: ComboStep): string[] {
return step.kind === "model" && Array.isArray(step.tags) ? step.tags : [];
}
function getStepLabel(step: ComboStep): string {
if (step.label) return step.label;
if (step.kind === "combo-ref") return `Combo → ${step.comboName}`;
return step.model;
}
export function getComboControlCenterTargets(
combo: ComboControlCenterCombo,
health?: ComboControlCenterHealth | null
): ComboControlCenterTarget[] {
const steps = normalizeComboModels(combo.models || [], { comboName: combo.name || null });
const healthByStepId = new Map<string, ComboControlCenterTargetHealth>();
const healthByModel = new Map<string, ComboControlCenterTargetHealth>();
for (const target of health?.targetHealth || []) {
const stepId = toString(target.stepId);
const model = toString(target.model);
if (stepId) healthByStepId.set(stepId, target);
if (model && !healthByModel.has(model)) healthByModel.set(model, target);
}
return steps.map((step, index) => {
const model = step.kind === "combo-ref" ? step.comboName : step.model;
const healthEntry = healthByStepId.get(step.id) || healthByModel.get(model) || null;
const provider =
step.kind === "model"
? step.providerId || providerFromModel(step.model) || healthEntry?.provider || null
: null;
return {
id: step.id,
kind: step.kind,
index,
label: getStepLabel(step),
model,
provider,
connectionId: step.kind === "model" ? step.connectionId || null : null,
weight: step.weight || 0,
tags: getStepTags(step),
health: healthEntry,
};
});
}
export function getResolvedComboControlCenterTargets(
health?: ComboControlCenterHealth | null
): ComboControlCenterTargetHealth[] {
return Array.isArray(health?.targetHealth) ? health.targetHealth : [];
}
export function summarizeComboControlCenter(
combo: ComboControlCenterCombo,
metrics?: ComboControlCenterMetrics | null,
health?: ComboControlCenterHealth | null
): ComboControlCenterSummary {
const targets = getComboControlCenterTargets(combo, health);
const resolvedTargets = getResolvedComboControlCenterTargets(health);
const providers = new Set<string>();
for (const target of targets) {
if (target.provider) providers.add(target.provider);
}
for (const target of resolvedTargets) {
const provider = toString(target.provider);
if (provider) providers.add(provider);
}
const healthRequests = getHealthRequests(health);
const metricRequests = getMetricRequests(metrics);
const totalRequests = healthRequests || metricRequests;
const successRate =
healthRequests > 0
? normalizeSuccessRate(health?.performance?.successRate)
: normalizeSuccessRate(metrics?.successRate);
const avgLatencyMs =
toNumber(health?.performance?.avgLatencyMs, 0) || toNumber(metrics?.avgLatencyMs, 0);
const fallbackRate = normalizeSuccessRate(metrics?.fallbackRate);
const worstQuotaRemainingPct =
typeof health?.quotaHealth?.worstRemainingPct === "number"
? health.quotaHealth.worstRemainingPct
: null;
const usageSkew = toNumber(health?.usageSkew?.giniCoefficient, 0);
const hasExhaustedQuota = Boolean(
health?.quotaHealth?.providers?.some((provider) => provider.isExhausted)
);
const healthReasons: string[] = [];
if (totalRequests === 0) healthReasons.push("No recent combo traffic");
if (successRate > 0 && successRate < 80) healthReasons.push("Low success rate");
else if (successRate > 0 && successRate < 95) healthReasons.push("Success rate below target");
if (fallbackRate >= 20) healthReasons.push("High fallback rate");
else if (fallbackRate >= 10) healthReasons.push("Elevated fallback rate");
if (hasExhaustedQuota) healthReasons.push("At least one quota is exhausted");
else if (worstQuotaRemainingPct !== null && worstQuotaRemainingPct < 10) {
healthReasons.push("Quota is nearly exhausted");
} else if (worstQuotaRemainingPct !== null && worstQuotaRemainingPct < 25) {
healthReasons.push("Quota is getting low");
}
if (usageSkew >= 0.5) healthReasons.push("Traffic distribution is highly skewed");
let healthState: ComboControlCenterHealthState = "healthy";
if (totalRequests === 0 && worstQuotaRemainingPct === null) {
healthState = "idle";
} else if (
hasExhaustedQuota ||
(successRate > 0 && successRate < 80) ||
(worstQuotaRemainingPct !== null && worstQuotaRemainingPct < 10)
) {
healthState = "critical";
} else if (
(successRate > 0 && successRate < 95) ||
fallbackRate >= 10 ||
(worstQuotaRemainingPct !== null && worstQuotaRemainingPct < 25) ||
usageSkew >= 0.5
) {
healthState = "warning";
}
return {
strategy: combo.strategy || "priority",
isActive: combo.isActive !== false,
targetCount: targets.length,
modelTargetCount: targets.filter((target) => target.kind === "model").length,
nestedComboCount: targets.filter((target) => target.kind === "combo-ref").length,
providerCount: providers.size,
totalRequests,
successRate,
avgLatencyMs,
fallbackRate,
worstQuotaRemainingPct,
usageSkew,
healthState,
healthReasons: healthReasons.length > 0 ? healthReasons : ["Combo looks healthy"],
};
}
export function extractComboRuntimeConfig(combo: ComboControlCenterCombo): JsonRecord {
return isRecord(combo.config) ? combo.config : {};
}
+192
View File
@@ -0,0 +1,192 @@
type JsonRecord = Record<string, unknown>;
export const INTELLIGENT_STRATEGIES = ["auto", "lkgp"] as const;
export const INTELLIGENT_ROUTING_FILTERS = ["all", "intelligent", "deterministic"] as const;
export type IntelligentRoutingFilter = (typeof INTELLIGENT_ROUTING_FILTERS)[number];
export type IntelligentRoutingWeights = {
quota: number;
health: number;
costInv: number;
latencyInv: number;
taskFit: number;
stability: number;
tierPriority: number;
tierAffinity: number;
specificityMatch: number;
contextAffinity: number;
resetWindowAffinity: number;
};
export type IntelligentRoutingConfig = {
candidatePool: string[];
explorationRate: number;
modePack: string;
budgetCap?: number;
weights: IntelligentRoutingWeights;
routerStrategy: string;
slaTargetP95Ms?: number;
slaMaxErrorRate?: number;
slaMaxCostPer1MTokens?: number;
slaHardConstraints: boolean;
};
export type IntelligentProviderScore = {
provider: string;
model: string;
score: number;
factors: IntelligentRoutingWeights;
};
export const DEFAULT_INTELLIGENT_WEIGHTS: IntelligentRoutingWeights = {
quota: 0.16,
health: 0.2,
costInv: 0.16,
latencyInv: 0.12,
taskFit: 0.08,
stability: 0.05,
tierPriority: 0.05,
tierAffinity: 0.05,
specificityMatch: 0.05,
contextAffinity: 0.08,
resetWindowAffinity: 0,
};
export const MODE_PACK_OPTIONS = [
{ id: "ship-fast", label: "Ship Fast", emoji: "rocket_launch" },
{ id: "cost-saver", label: "Cost Saver", emoji: "savings" },
{ id: "quality-first", label: "Quality First", emoji: "target" },
{ id: "offline-friendly", label: "Offline Friendly", emoji: "cloud_off" },
] as const;
export const ROUTER_STRATEGY_OPTIONS = [
{ id: "rules", label: "Rules (6-Factor Scoring)" },
{ id: "cost", label: "Cost Optimized" },
{ id: "latency", label: "Latency Optimized" },
{ id: "sla-aware", label: "SLA-aware" },
{ id: "lkgp", label: "Last Known Good Provider" },
] as const;
export const FACTOR_LABELS: Record<keyof IntelligentRoutingWeights, string> = {
quota: "Quota",
health: "Health",
costInv: "Cost",
latencyInv: "Latency",
taskFit: "Task Fit",
stability: "Stability",
tierPriority: "Tier",
tierAffinity: "Tier Affinity",
specificityMatch: "Specificity",
contextAffinity: "Context Affinity",
resetWindowAffinity: "Reset Window",
};
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value: unknown): number | null {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
function toPositiveNumber(value: unknown): number | undefined {
const numericValue = toFiniteNumber(value);
return numericValue !== null && numericValue > 0 ? numericValue : undefined;
}
export function isIntelligentStrategy(strategy: unknown): boolean {
return typeof strategy === "string" && INTELLIGENT_STRATEGIES.includes(strategy as never);
}
export function getStrategyCategory(strategy: unknown): "intelligent" | "deterministic" {
return isIntelligentStrategy(strategy) ? "intelligent" : "deterministic";
}
export function normalizeIntelligentRoutingFilter(value: unknown): IntelligentRoutingFilter {
if (typeof value === "string" && INTELLIGENT_ROUTING_FILTERS.includes(value as never)) {
return value as IntelligentRoutingFilter;
}
return "all";
}
export function filterCombosByStrategyCategory<T extends { strategy?: unknown }>(
combos: T[],
filter: IntelligentRoutingFilter
): T[] {
if (filter === "all") return combos;
return combos.filter((combo) => getStrategyCategory(combo?.strategy) === filter);
}
export function normalizeIntelligentRoutingConfig(config: unknown): IntelligentRoutingConfig {
const configRecord = isRecord(config) ? config : {};
const rawWeights = isRecord(configRecord.weights) ? configRecord.weights : {};
const rawSla = isRecord(configRecord.sla) ? configRecord.sla : {};
const slaTargetP95Ms = configRecord.slaTargetP95Ms ?? rawSla.targetP95Ms;
const slaMaxErrorRate = toFiniteNumber(configRecord.slaMaxErrorRate ?? rawSla.maxErrorRate);
const slaMaxCostPer1MTokens = configRecord.slaMaxCostPer1MTokens ?? rawSla.maxCostPer1MTokens;
const slaHardConstraints = configRecord.slaHardConstraints ?? rawSla.hardConstraints;
return {
candidatePool: Array.isArray(configRecord.candidatePool)
? configRecord.candidatePool.filter((value): value is string => typeof value === "string")
: [],
explorationRate: Math.min(1, Math.max(0, toFiniteNumber(configRecord.explorationRate) ?? 0.05)),
modePack:
typeof configRecord.modePack === "string" && configRecord.modePack.trim().length > 0
? configRecord.modePack
: "ship-fast",
budgetCap: toPositiveNumber(configRecord.budgetCap),
weights: {
quota: toFiniteNumber(rawWeights.quota) ?? DEFAULT_INTELLIGENT_WEIGHTS.quota,
health: toFiniteNumber(rawWeights.health) ?? DEFAULT_INTELLIGENT_WEIGHTS.health,
costInv: toFiniteNumber(rawWeights.costInv) ?? DEFAULT_INTELLIGENT_WEIGHTS.costInv,
latencyInv: toFiniteNumber(rawWeights.latencyInv) ?? DEFAULT_INTELLIGENT_WEIGHTS.latencyInv,
taskFit: toFiniteNumber(rawWeights.taskFit) ?? DEFAULT_INTELLIGENT_WEIGHTS.taskFit,
stability: toFiniteNumber(rawWeights.stability) ?? DEFAULT_INTELLIGENT_WEIGHTS.stability,
tierPriority:
toFiniteNumber(rawWeights.tierPriority) ?? DEFAULT_INTELLIGENT_WEIGHTS.tierPriority,
tierAffinity:
toFiniteNumber(rawWeights.tierAffinity) ?? DEFAULT_INTELLIGENT_WEIGHTS.tierAffinity,
specificityMatch:
toFiniteNumber(rawWeights.specificityMatch) ?? DEFAULT_INTELLIGENT_WEIGHTS.specificityMatch,
contextAffinity:
toFiniteNumber(rawWeights.contextAffinity) ?? DEFAULT_INTELLIGENT_WEIGHTS.contextAffinity,
resetWindowAffinity:
toFiniteNumber(rawWeights.resetWindowAffinity) ??
DEFAULT_INTELLIGENT_WEIGHTS.resetWindowAffinity,
},
routerStrategy:
typeof configRecord.routerStrategy === "string" &&
configRecord.routerStrategy.trim().length > 0
? configRecord.routerStrategy
: "rules",
slaTargetP95Ms: toPositiveNumber(slaTargetP95Ms),
slaMaxErrorRate:
slaMaxErrorRate !== null ? Math.min(1, Math.max(0, slaMaxErrorRate)) : undefined,
slaMaxCostPer1MTokens: toPositiveNumber(slaMaxCostPer1MTokens),
slaHardConstraints: slaHardConstraints === true,
};
}
export function buildIntelligentProviderScores(combo: {
config?: unknown;
weights?: unknown;
}): IntelligentProviderScore[] {
const configRecord = normalizeIntelligentRoutingConfig(combo?.config);
const comboWeights = isRecord(combo?.weights) ? combo.weights : combo?.config;
const weights = normalizeIntelligentRoutingConfig({
...(isRecord(comboWeights) ? comboWeights : {}),
weights: isRecord(combo?.weights) ? combo.weights : configRecord.weights,
}).weights;
const pool = configRecord.candidatePool;
const baseScore = pool.length > 0 ? 1 / pool.length : 0;
return pool.map((provider) => ({
provider,
model: "auto",
score: baseScore,
factors: weights,
}));
}
+322
View File
@@ -0,0 +1,322 @@
type JsonRecord = Record<string, unknown>;
export const COMBO_SCHEMA_VERSION = 2;
export interface ComboModelStep {
id: string;
kind: "model";
model: string;
providerId?: string | null;
connectionId?: string | null;
/**
* Account allowlist (#3266): scope this step's round-robin/weighted selection
* to a subset of the provider's connections. Empty/absent = whole active pool.
* Reuses the `allowedConnectionIds` plumbing tag routing already populates.
*/
allowedConnectionIds?: string[] | null;
weight: number;
label?: string;
tags?: string[];
}
export interface ComboRefStep {
id: string;
kind: "combo-ref";
comboName: string;
weight: number;
label?: string;
}
export type ComboStep = ComboModelStep | ComboRefStep;
type ComboCollectionLike =
| Array<{ name?: unknown } | string>
| { combos?: Array<{ name?: unknown }> }
| Iterable<string>
| null
| undefined;
interface NormalizeComboStepOptions {
comboName?: string | null;
index?: number;
allCombos?: ComboCollectionLike;
}
interface NormalizeComboRecordOptions {
allCombos?: ComboCollectionLike;
}
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toTrimmedString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function toWeight(value: unknown): number {
const parsed =
typeof value === "number"
? value
: typeof value === "string" && value.trim().length > 0
? Number(value)
: 0;
if (!Number.isFinite(parsed)) return 0;
return Math.max(0, Math.min(100, parsed));
}
function collectComboNames(allCombos: ComboCollectionLike): Set<string> {
const names = new Set<string>();
if (!allCombos) return names;
if (
!Array.isArray(allCombos) &&
typeof (allCombos as { [Symbol.iterator]?: unknown })[Symbol.iterator] === "function" &&
!(isRecord(allCombos) && Array.isArray(allCombos.combos))
) {
for (const value of allCombos as Iterable<string>) {
const name = toTrimmedString(value);
if (name) names.add(name);
}
return names;
}
const combosFromRecord =
isRecord(allCombos) && Array.isArray(allCombos.combos) ? allCombos.combos : [];
const combos = Array.isArray(allCombos) ? allCombos : combosFromRecord;
for (const combo of combos) {
const name = typeof combo === "string" ? toTrimmedString(combo) : toTrimmedString(combo?.name);
if (name) names.add(name);
}
return names;
}
function parseProviderId(model: string): string | null {
const trimmed = model.trim();
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0) return null;
const providerId = trimmed.slice(0, slashIndex).trim();
return providerId.length > 0 ? providerId : null;
}
function toFullModelString(model: string, providerId?: string | null): string {
const trimmedModel = model.trim();
if (trimmedModel.includes("/")) return trimmedModel;
const normalizedProviderId = toTrimmedString(providerId);
return normalizedProviderId ? `${normalizedProviderId}/${trimmedModel}` : trimmedModel;
}
function slugify(value: string): string {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "step";
}
function buildStepId(
kind: ComboStep["kind"],
comboName: string | null,
index: number,
seed: string
) {
const parts = [
slugify(comboName || "combo"),
kind === "combo-ref" ? "ref" : "model",
String(index + 1),
slugify(seed),
];
return parts.join("-").slice(0, 200);
}
function shouldTreatAsComboRef(
target: string,
providerId: string | null,
options: NormalizeComboStepOptions
): boolean {
if (providerId) return false;
const comboNames = collectComboNames(options.allCombos);
return comboNames.has(target) || target === toTrimmedString(options.comboName);
}
export function getComboStepWeight(value: unknown): number {
if (typeof value === "string") return 0;
if (!isRecord(value)) return 0;
return toWeight(value.weight);
}
export function getComboModelString(value: unknown): string | null {
if (typeof value === "string") return toTrimmedString(value);
if (!isRecord(value) || value.kind === "combo-ref") return null;
const rawModel = toTrimmedString(value.model);
if (!rawModel) return null;
const providerId =
toTrimmedString(value.providerId) ||
toTrimmedString(value.provider) ||
parseProviderId(rawModel);
return toFullModelString(rawModel, providerId);
}
export function getComboModelProvider(value: unknown): string | null {
if (typeof value === "string") return parseProviderId(value);
if (!isRecord(value) || value.kind === "combo-ref") return null;
return (
toTrimmedString(value.providerId) ||
toTrimmedString(value.provider) ||
parseProviderId(toTrimmedString(value.model) || "")
);
}
export function getComboStepTarget(
value: unknown,
options: NormalizeComboStepOptions = {}
): string | null {
if (typeof value === "string") {
const target = toTrimmedString(value);
if (!target) return null;
return shouldTreatAsComboRef(target, null, options) ? target : target;
}
if (!isRecord(value)) return null;
if (value.kind === "combo-ref") return toTrimmedString(value.comboName);
const rawModel = toTrimmedString(value.model);
if (!rawModel) return null;
const isExplicitModel = value.kind === "model";
const providerId =
toTrimmedString(value.providerId) ||
toTrimmedString(value.provider) ||
parseProviderId(rawModel);
if (!isExplicitModel && shouldTreatAsComboRef(rawModel, providerId, options)) {
return rawModel;
}
return toFullModelString(rawModel, providerId);
}
export function normalizeComboStep(
value: unknown,
options: NormalizeComboStepOptions = {}
): ComboStep | null {
const comboName = toTrimmedString(options.comboName);
const index = typeof options.index === "number" ? options.index : 0;
if (typeof value === "string") {
const target = toTrimmedString(value);
if (!target) return null;
if (shouldTreatAsComboRef(target, null, options)) {
return {
id: buildStepId("combo-ref", comboName, index, target),
kind: "combo-ref",
comboName: target,
weight: 0,
};
}
const providerId = parseProviderId(target);
return {
id: buildStepId("model", comboName, index, target),
kind: "model",
model: target,
...(providerId ? { providerId } : {}),
weight: 0,
};
}
if (!isRecord(value)) return null;
const explicitId = toTrimmedString(value.id);
const weight = toWeight(value.weight);
const label = toTrimmedString(value.label);
if (value.kind === "combo-ref") {
const comboRefName = toTrimmedString(value.comboName);
if (!comboRefName) return null;
return {
id: explicitId || buildStepId("combo-ref", comboName, index, comboRefName),
kind: "combo-ref",
comboName: comboRefName,
weight,
...(label ? { label } : {}),
};
}
const rawModel = toTrimmedString(value.model);
if (!rawModel) return null;
const isExplicitModel = value.kind === "model";
const providerId =
toTrimmedString(value.providerId) ||
toTrimmedString(value.provider) ||
parseProviderId(rawModel);
if (!isExplicitModel && shouldTreatAsComboRef(rawModel, providerId, options)) {
return {
id: explicitId || buildStepId("combo-ref", comboName, index, rawModel),
kind: "combo-ref",
comboName: rawModel,
weight,
...(label ? { label } : {}),
};
}
const model = toFullModelString(rawModel, providerId);
const connectionId =
value.connectionId === null ? null : toTrimmedString(value.connectionId) || undefined;
const tags = Array.isArray(value.tags)
? value.tags.map((tag) => toTrimmedString(tag)).filter((tag): tag is string => !!tag)
: undefined;
const allowedConnectionIds = Array.isArray(value.allowedConnectionIds)
? value.allowedConnectionIds
.map((connId) => toTrimmedString(connId))
.filter((connId): connId is string => !!connId)
: undefined;
return {
id:
explicitId ||
buildStepId("model", comboName, index, connectionId ? `${model}:${connectionId}` : model),
kind: "model",
model,
...(providerId ? { providerId } : {}),
...(connectionId !== undefined ? { connectionId } : {}),
weight,
...(label ? { label } : {}),
...(tags && tags.length > 0 ? { tags } : {}),
...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}),
};
}
export function normalizeComboModels(
models: unknown,
options: Omit<NormalizeComboStepOptions, "index"> = {}
): ComboStep[] {
const list = Array.isArray(models) ? models : [];
return list
.map((value, index) => normalizeComboStep(value, { ...options, index }))
.filter((value): value is ComboStep => value !== null);
}
export function normalizeComboRecord<T extends JsonRecord>(
combo: T,
options: NormalizeComboRecordOptions = {}
): T & { version: 2; models: ComboStep[] } {
const comboName = toTrimmedString(combo.name);
return {
...combo,
version: COMBO_SCHEMA_VERSION,
models: normalizeComboModels(combo.models, {
comboName,
allCombos: options.allCombos,
}),
};
}
+190
View File
@@ -0,0 +1,190 @@
type JsonRecord = Record<string, unknown>;
const COMBO_TEST_MAX_TOKENS = 2048;
const COMBO_TEST_OPERAND_MIN = 10000;
const COMBO_TEST_OPERAND_RANGE = 90000;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function joinNonEmpty(parts: string[]) {
return parts.filter(Boolean).join("\n").trim();
}
function extractTextFromContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
return content
.map((part) => {
if (typeof part === "string") return part.trim();
const block = asRecord(part);
const blockType = typeof block.type === "string" ? block.type : "";
const blockText = typeof block.text === "string" ? block.text.trim() : "";
if (blockText && (blockType === "" || blockType === "text" || blockType === "output_text")) {
return blockText;
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
function extractReasoningText(record: JsonRecord): string {
const reasoningDetails = Array.isArray(record.reasoning_details) ? record.reasoning_details : [];
const detailText = reasoningDetails
.map((detail) => {
const detailRecord = asRecord(detail);
const detailType = typeof detailRecord.type === "string" ? detailRecord.type : "";
const text =
typeof detailRecord.text === "string"
? detailRecord.text.trim()
: typeof detailRecord.content === "string"
? detailRecord.content.trim()
: "";
if (
text &&
(detailType === "" ||
detailType === "reasoning" ||
detailType === "reasoning.text" ||
detailType === "thinking")
) {
return text;
}
return "";
})
.filter(Boolean);
return joinNonEmpty([
typeof record.reasoning_content === "string" ? record.reasoning_content.trim() : "",
typeof record.reasoning === "string" ? record.reasoning.trim() : "",
typeof record.reasoning_text === "string" ? record.reasoning_text.trim() : "",
joinNonEmpty(detailText),
]);
}
function getUsageReasoningTokens(body: JsonRecord): number {
const usage = asRecord(body.usage);
if (!usage) return 0;
const completionDetails = asRecord(usage.completion_tokens_details);
const topLevelReasoning =
typeof usage.reasoning_tokens === "number" && Number.isFinite(usage.reasoning_tokens)
? usage.reasoning_tokens
: 0;
const detailedReasoning =
typeof completionDetails.reasoning_tokens === "number" &&
Number.isFinite(completionDetails.reasoning_tokens)
? completionDetails.reasoning_tokens
: 0;
return Math.max(topLevelReasoning, detailedReasoning);
}
function hasReasoningOnlyCompletion(body: JsonRecord): boolean {
if (!Array.isArray(body.choices) || body.choices.length === 0) return false;
if (getUsageReasoningTokens(body) <= 0) return false;
return body.choices.some((choice) => {
const choiceRecord = asRecord(choice);
const message = asRecord(choiceRecord.message);
const finishReason =
typeof choiceRecord.finish_reason === "string" ? choiceRecord.finish_reason : "";
if (!message || message.role !== "assistant") return false;
if (!finishReason) return false;
if (extractTextFromContent(message.content)) return false;
if (extractReasoningText(message)) return false;
return true;
});
}
function getRandomFiveDigitNumber() {
return COMBO_TEST_OPERAND_MIN + Math.floor(Math.random() * COMBO_TEST_OPERAND_RANGE);
}
function buildComboTestPrompt() {
const left = getRandomFiveDigitNumber();
const right = getRandomFiveDigitNumber();
return `Calculate ${left}+${right}, and reply with the result only.`;
}
export function buildComboTestRequestBody(modelStr: string, isEmbedding: boolean = false) {
if (isEmbedding) {
return {
model: modelStr,
input: "Hello World",
};
}
return {
model: modelStr,
// Randomize the arithmetic prompt so upstream providers are less likely to
// satisfy the smoke test with cached completions.
messages: [{ role: "user", content: buildComboTestPrompt() }],
// Give reasoning-heavy models enough headroom to finish the request and
// still emit a visible answer without immediate truncation.
max_tokens: COMBO_TEST_MAX_TOKENS,
stream: false,
};
}
export function extractComboTestResponseText(responseBody: unknown): string {
const body = asRecord(responseBody);
if (typeof body.output_text === "string" && body.output_text.trim()) {
return body.output_text.trim();
}
if (Array.isArray(body.data) && body.data[0]?.embedding) {
return "[Embedding generated successfully]";
}
if (Array.isArray(body.choices)) {
for (const choice of body.choices) {
const choiceRecord = asRecord(choice);
const message = asRecord(choiceRecord.message);
const messageText = extractTextFromContent(message.content);
if (messageText) return messageText;
const reasoningText = extractReasoningText(message);
if (reasoningText) return reasoningText;
if (typeof choiceRecord.text === "string" && choiceRecord.text.trim()) {
return choiceRecord.text.trim();
}
}
}
if (Array.isArray(body.output)) {
for (const item of body.output) {
const itemRecord = asRecord(item);
const contentText = extractTextFromContent(itemRecord.content);
if (contentText) return contentText;
const reasoningText = extractReasoningText(itemRecord);
if (reasoningText) return reasoningText;
}
}
const topLevelText = extractTextFromContent(body.content);
if (topLevelText) return topLevelText;
const topLevelReasoning = extractReasoningText(body);
if (topLevelReasoning) return topLevelReasoning;
if (hasReasoningOnlyCompletion(body)) {
return "[reasoning-only completion]";
}
return "";
}
+637
View File
@@ -0,0 +1,637 @@
/**
* Compliance Controls — T-43
*
* Implements compliance features:
* - APP_LOG_RETENTION_DAYS / CALL_LOG_RETENTION_DAYS: automatic log cleanup
* - noLog opt-out per API key
* - audit_log table for administrative actions
*
* @module lib/compliance
*/
import { getDbInstance } from "../db/core";
import type { SqliteAdapter } from "@/lib/db/adapters/types";
import { getClientIpFromRequest } from "../ipUtils";
import {
getAppLogRetentionDays,
getCallLogRetentionDays,
getAppLogRetentionDaysOverride,
getCallLogRetentionDaysOverride,
getCallLogsTableMaxRows,
getProxyLogsTableMaxRows,
} from "../logEnv";
import { getUserDatabaseSettings } from "../db/databaseSettings";
import { generateRequestId, getRequestId } from "@/shared/utils/requestId";
import { HIGH_LEVEL_ACTIONS } from "@/lib/audit/highLevelActions";
/** @returns {SqliteAdapter | null} */
function getDb() {
try {
return getDbInstance();
} catch {
return null;
}
}
type AuditLogWriteEntry = {
action: string;
actor?: string;
target?: string;
details?: unknown;
metadata?: unknown;
ipAddress?: string;
resourceType?: string;
status?: string;
requestId?: string;
createdAt?: string;
};
type AuditLogFilter = {
action?: string;
actor?: string;
target?: string;
resourceType?: string;
status?: string;
requestId?: string;
from?: string;
to?: string;
limit?: number;
offset?: number;
/** When "high", restricts results to HIGH_LEVEL_ACTIONS only (B7). */
levelFilter?: "high";
};
type AuditLogRow = Record<string, unknown> & {
id?: number | null;
action?: string | null;
actor?: string | null;
target?: string | null;
status?: string | null;
details?: string | null;
metadata?: string | null;
ip_address?: string | null;
resource_type?: string | null;
request_id?: string | null;
timestamp?: string | null;
};
/**
* Public shape of a normalized audit-log entry returned by `getAuditLog` /
* `normalizeAuditLogRow`. Includes the column-level fields callers (and
* tests) reach into directly — `action`, `actor`, `target`, `id`, `status`,
* `timestamp` — alongside the derived/parsed fields. The
* `Record<string, unknown>` intersection preserves the existing behaviour of
* spreading any extra DB columns (e.g. schema additions) without losing
* compile-time access to the known ones.
*/
export type AuditLogEntry = Record<string, unknown> & {
id?: number | null;
action?: string | null;
actor?: string | null;
target?: string | null;
status: string | null;
timestamp: string;
createdAt: string;
details: unknown;
metadata: unknown;
ip_address: string | null;
ip: string | null;
resource_type: string | null;
resourceType: string | null;
request_id: string | null;
requestId: string | null;
};
const AUDIT_LOG_REQUIRED_COLUMNS: Record<string, string> = {
resource_type: "TEXT",
status: "TEXT",
request_id: "TEXT",
metadata: "TEXT",
};
const SENSITIVE_AUDIT_KEYS = new Set([
"apikey",
"accesstoken",
"refreshtoken",
"idtoken",
"authtoken",
"jwttoken",
"token",
"secret",
"password",
"authorization",
"cookie",
"setcookie",
"consoleapikey",
"clientsecret",
]);
function normalizeAuditKey(key: string) {
return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
}
function isSensitiveAuditKey(key: string) {
const normalized = normalizeAuditKey(key);
if (!normalized) return false;
if (SENSITIVE_AUDIT_KEYS.has(normalized)) return true;
return (
normalized.endsWith("apikey") ||
normalized.endsWith("token") ||
normalized.endsWith("secret") ||
normalized.endsWith("password")
);
}
function sanitizeAuditValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => sanitizeAuditValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
key,
isSensitiveAuditKey(key) ? "[redacted]" : sanitizeAuditValue(nestedValue),
])
);
}
return value;
}
function serializeAuditValue(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
const sanitizedValue = sanitizeAuditValue(value);
if (typeof sanitizedValue === "string") {
return sanitizedValue;
}
try {
return JSON.stringify(sanitizedValue);
} catch {
return String(sanitizedValue);
}
}
function parseAuditValue(value: unknown): unknown {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function ensureAuditLogSchema(db: SqliteAdapter) {
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
action TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT,
details TEXT,
ip_address TEXT,
resource_type TEXT,
status TEXT,
request_id TEXT,
metadata TEXT
);
`);
let columns: Array<{ name: string }> = [];
try {
columns = db.prepare("PRAGMA table_info(audit_log)").all() as Array<{ name: string }>;
} catch {
columns = [];
}
const existingColumns = new Set(columns.map((column) => column.name));
for (const [columnName, columnType] of Object.entries(AUDIT_LOG_REQUIRED_COLUMNS)) {
if (existingColumns.has(columnName)) continue;
try {
db.exec(`ALTER TABLE audit_log ADD COLUMN ${columnName} ${columnType}`);
} catch {
// Another worker may have upgraded the schema first. Ignore.
}
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_resource_type ON audit_log(resource_type);
CREATE INDEX IF NOT EXISTS idx_audit_status ON audit_log(status);
CREATE INDEX IF NOT EXISTS idx_audit_request_id ON audit_log(request_id);
`);
}
type AuditLogQuery = {
where: string;
params: string[];
};
function buildAuditLogQuery(filter: AuditLogFilter = {}): AuditLogQuery {
const conditions: string[] = [];
const params: string[] = [];
const addLikeFilter = (column: string, value?: string) => {
if (!value) return;
conditions.push(`${column} LIKE ?`);
params.push(`%${value}%`);
};
addLikeFilter("action", filter.action);
addLikeFilter("actor", filter.actor);
addLikeFilter("target", filter.target);
addLikeFilter("resource_type", filter.resourceType);
addLikeFilter("status", filter.status);
addLikeFilter("request_id", filter.requestId);
if (filter.from) {
conditions.push("datetime(timestamp) >= datetime(?)");
params.push(filter.from);
}
if (filter.to) {
conditions.push("datetime(timestamp) <= datetime(?)");
params.push(filter.to);
}
// B7: level=high filter — restrict to HIGH_LEVEL_ACTIONS via parameterized IN clause
if (filter.levelFilter === "high" && HIGH_LEVEL_ACTIONS.length > 0) {
const placeholders = HIGH_LEVEL_ACTIONS.map(() => "?").join(", ");
conditions.push(`action IN (${placeholders})`);
// HIGH_LEVEL_ACTIONS is readonly — create a mutable copy for spread
params.push(...Array.from(HIGH_LEVEL_ACTIONS));
}
return {
where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
params,
};
}
function normalizeAuditLogRow(row: AuditLogRow): AuditLogEntry {
const details = parseAuditValue(row.details);
const metadata = parseAuditValue(row.metadata);
const resourceType = typeof row.resource_type === "string" ? row.resource_type : null;
const requestId = typeof row.request_id === "string" ? row.request_id : null;
const ip = typeof row.ip_address === "string" ? row.ip_address : null;
const timestamp = typeof row.timestamp === "string" ? row.timestamp : new Date().toISOString();
return {
...(row as Record<string, unknown>),
timestamp,
createdAt: timestamp,
details,
metadata: metadata ?? (details && typeof details === "object" ? details : null),
ip_address: ip,
ip,
resource_type: resourceType,
resourceType,
request_id: requestId,
requestId,
status: typeof row.status === "string" ? row.status : null,
};
}
export function getAuditRequestContext(request?: {
headers?: Headers | { get?: (name: string) => string | null };
socket?: { remoteAddress?: string };
ip?: string;
}) {
return {
ipAddress: request ? getClientIpFromRequest(request) : null,
requestId: getRequestId() || request?.headers?.get?.("x-request-id") || generateRequestId(),
};
}
/**
* Initialize the audit_log table.
*/
export function initAuditLog() {
const db = getDb();
if (!db) return;
ensureAuditLogSchema(db);
}
/**
* Log an administrative action.
*
* @param {Object} entry
* @param {string} entry.action - Action type (e.g. "settings.update", "apiKey.create", "password.reset")
* @param {string} [entry.actor="system"] - Who performed the action
* @param {string} [entry.target] - What was affected
* @param {Object|string} [entry.details] - Additional details
* @param {string} [entry.ipAddress] - Client IP
*/
export function logAuditEvent(entry: {
action: string;
actor?: string;
target?: string;
details?: unknown;
metadata?: unknown;
ipAddress?: string;
resourceType?: string;
status?: string;
requestId?: string;
createdAt?: string;
}) {
const db = getDb();
if (!db) return;
try {
ensureAuditLogSchema(db);
const createdAt = entry.createdAt || new Date().toISOString();
const serializedDetails = serializeAuditValue(entry.details ?? entry.metadata);
const metadataSource =
entry.metadata !== undefined
? entry.metadata
: entry.details && typeof entry.details === "object"
? entry.details
: null;
const stmt = db.prepare(`
INSERT INTO audit_log (
timestamp,
action,
actor,
target,
details,
ip_address,
resource_type,
status,
request_id,
metadata
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
createdAt,
entry.action,
entry.actor || "system",
entry.target || null,
serializedDetails,
entry.ipAddress || null,
entry.resourceType || null,
entry.status || null,
entry.requestId || null,
serializeAuditValue(metadataSource)
);
} catch {
// Silently fail — audit logging should never break the main flow
}
}
/**
* Query audit log entries.
*
* @param {Object} [filter={}]
* @param {string} [filter.action] - Filter by action type
* @param {string} [filter.actor] - Filter by actor
* @param {number} [filter.limit=100] - Max results
* @param {number} [filter.offset=0] - Pagination offset
* @returns {Array<{ id: number, timestamp: string, action: string, actor: string, target: string, details: any, ip_address: string }>}
*/
export function getAuditLog(filter: AuditLogFilter = {}): AuditLogEntry[] {
const db = getDb();
if (!db) return [];
ensureAuditLogSchema(db);
const { where, params } = buildAuditLogQuery(filter);
const limit = Number.isFinite(filter.limit)
? Math.max(1, Math.min(500, filter.limit || 100))
: 100;
const offset = Number.isFinite(filter.offset) ? Math.max(0, filter.offset || 0) : 0;
const rows = db
.prepare(`SELECT * FROM audit_log ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`)
.all(...params, limit, offset) as AuditLogRow[];
return rows.map((row) => normalizeAuditLogRow(row));
}
export function countAuditLog(filter: AuditLogFilter = {}) {
const db = getDb();
if (!db) return 0;
ensureAuditLogSchema(db);
const { where, params } = buildAuditLogQuery(filter);
const row = db.prepare(`SELECT COUNT(*) as count FROM audit_log ${where}`).get(...params) as
| { count?: number }
| undefined;
return Number(row?.count || 0);
}
// ─── No-Log Opt-Out ────────────────
// Moved to ./noLog.ts to break the callLogs → compliance → callLogs ESM cycle
// that deadlocks the bundled MCP server under Node.js 24 (#2650). Re-exported
// here so downstream callers that import from "../compliance" keep working.
export { isNoLog, setNoLog } from "./noLog";
// ─── Log Retention / Cleanup ────────────────
/**
* Get the configured retention periods.
*/
export function getRetentionDays() {
return {
app: getAppLogRetentionDays(),
call: getCallLogRetentionDays(),
};
}
/**
* Clean up logs using split APP/CALL retention windows.
* Should be called periodically (e.g. daily cron or on startup).
*
* @returns {{
* deletedUsage: number,
* deletedCallLogs: number,
* deletedProxyLogs: number,
* deletedRequestDetailLogs: number,
* deletedAuditLogs: number,
* deletedMcpAuditLogs: number,
* trimmedCallLogs: number,
* trimmedProxyLogs: number,
* appRetentionDays: number,
* callRetentionDays: number,
* callLogsMaxRows: number,
* proxyLogsMaxRows: number
* }}
*/
export async function cleanupExpiredLogs() {
const db = getDb();
const appRetentionDays = getAppLogRetentionDays();
const callRetentionDays = getCallLogRetentionDays();
const callLogsMaxRows = getCallLogsTableMaxRows();
const proxyLogsMaxRows = getProxyLogsTableMaxRows();
if (!db) {
return {
deletedUsage: 0,
deletedCallLogs: 0,
deletedProxyLogs: 0,
deletedRequestDetailLogs: 0,
deletedAuditLogs: 0,
deletedMcpAuditLogs: 0,
trimmedCallLogs: 0,
trimmedProxyLogs: 0,
appRetentionDays,
callRetentionDays,
callLogsMaxRows,
proxyLogsMaxRows,
};
}
// #4354: retention precedence is explicit env override > dashboard DB setting > 7-day
// default. Previously this path always used the env default (7d), silently overriding a
// configured dashboard "Data Retention" (e.g. 90d) on every startup and trimming
// usage_history before the dashboard-based runAutoCleanup() could run. We now honor the
// dashboard retention per table when the operator did not set the env var, while still
// letting an explicit env var win (and falling back to env for non-DB deployments).
const callOverride = getCallLogRetentionDaysOverride();
const appOverride = getAppLogRetentionDaysOverride();
let dbRetention: { usageHistory: number; callLogs: number; mcpAudit: number } | null = null;
try {
const r = getUserDatabaseSettings().retention;
dbRetention = { usageHistory: r.usageHistory, callLogs: r.callLogs, mcpAudit: r.mcpAudit };
} catch {
/* settings table unavailable (e.g. very early startup) — keep env fallback */
}
const usageHistoryRetentionDays = callOverride ?? dbRetention?.usageHistory ?? callRetentionDays;
const callLogRetentionDays = callOverride ?? dbRetention?.callLogs ?? callRetentionDays;
const mcpAuditRetentionDays = appOverride ?? dbRetention?.mcpAudit ?? appRetentionDays;
const day = 24 * 60 * 60 * 1000;
const usageCutoff = new Date(Date.now() - usageHistoryRetentionDays * day).toISOString();
const callCutoff = new Date(Date.now() - callLogRetentionDays * day).toISOString();
const appCutoff = new Date(Date.now() - appRetentionDays * day).toISOString();
const mcpCutoff = new Date(Date.now() - mcpAuditRetentionDays * day).toISOString();
let deletedUsage = 0;
let deletedCallLogs = 0;
let deletedProxyLogs = 0;
let deletedRequestDetailLogs = 0;
let deletedAuditLogs = 0;
let deletedMcpAuditLogs = 0;
let trimmedCallLogs = 0;
let trimmedProxyLogs = 0;
try {
const r1 = db.prepare("DELETE FROM usage_history WHERE timestamp < ?").run(usageCutoff);
deletedUsage = r1.changes;
} catch {
/* table may not exist */
}
try {
const { deleteCallLogsBefore } = await import("../usage/callLogs");
const r2 = deleteCallLogsBefore(callCutoff);
deletedCallLogs = r2.deletedRows;
} catch {
/* table may not exist */
}
try {
const r3 = db.prepare("DELETE FROM proxy_logs WHERE timestamp < ?").run(callCutoff);
deletedProxyLogs = r3.changes;
} catch {
/* table may not exist */
}
try {
const r4 = db.prepare("DELETE FROM request_detail_logs WHERE timestamp < ?").run(callCutoff);
deletedRequestDetailLogs = r4.changes;
} catch {
/* legacy table may not exist */
}
try {
const r5 = db.prepare("DELETE FROM audit_log WHERE timestamp < ?").run(appCutoff);
deletedAuditLogs = r5.changes;
} catch {
/* table may not exist */
}
try {
const r6 = db.prepare("DELETE FROM mcp_tool_audit WHERE created_at < ?").run(mcpCutoff);
deletedMcpAuditLogs = r6.changes;
} catch {
/* table may not exist */
}
// Enforce row count limits to prevent unbounded DB growth (batched to avoid long locks)
const BATCH_SIZE = 5000;
if (callLogsMaxRows > 0) {
try {
const { trimCallLogsToMaxRows } = await import("../usage/callLogs");
const trimmed = trimCallLogsToMaxRows(callLogsMaxRows);
trimmedCallLogs = trimmed.deletedRows;
} catch {
/* best effort */
}
}
if (proxyLogsMaxRows > 0) {
try {
let currentProxyCount = db.prepare("SELECT COUNT(*) as cnt FROM proxy_logs").get() as {
cnt: number;
};
while (currentProxyCount.cnt > proxyLogsMaxRows) {
const toDelete = Math.min(currentProxyCount.cnt - proxyLogsMaxRows, BATCH_SIZE);
const trimmed = db
.prepare(
`DELETE FROM proxy_logs WHERE id IN (
SELECT id FROM proxy_logs ORDER BY timestamp ASC LIMIT ?
)`
)
.run(toDelete);
trimmedProxyLogs += trimmed.changes;
currentProxyCount.cnt -= trimmed.changes;
if (trimmed.changes === 0) break;
}
} catch {
/* best effort */
}
}
logAuditEvent({
action: "compliance.cleanup",
actor: "system",
target: "log-retention",
resourceType: "maintenance",
status: "success",
details: {
deletedUsage,
deletedCallLogs,
deletedProxyLogs,
deletedRequestDetailLogs,
deletedAuditLogs,
deletedMcpAuditLogs,
trimmedCallLogs,
trimmedProxyLogs,
appRetentionDays,
callRetentionDays,
callLogsMaxRows,
proxyLogsMaxRows,
},
});
return {
deletedUsage,
deletedCallLogs,
deletedProxyLogs,
deletedRequestDetailLogs,
deletedAuditLogs,
deletedMcpAuditLogs,
trimmedCallLogs,
trimmedProxyLogs,
appRetentionDays,
callRetentionDays,
callLogsMaxRows,
proxyLogsMaxRows,
};
}
+86
View File
@@ -0,0 +1,86 @@
import type { SqliteAdapter } from "@/lib/db/adapters/types";
import { getDbInstance } from "../db/core";
// #2650: extracted from compliance/index.ts to break the
// callLogs.ts → compliance/index.ts → callLogs.ts cycle that deadlocks
// the bundled MCP server under Node.js 24's stricter ESM evaluation.
function getDb(): SqliteAdapter | null {
try {
return getDbInstance();
} catch {
return null;
}
}
const noLogKeys = new Set<string>();
const noLogDbCache = new Map<string, { value: boolean; timestamp: number }>();
let noLogColumnVerified = false;
let hasNoLogColumn = false;
const NO_LOG_CACHE_TTL_MS = 30_000;
const noLogIdsFromEnv = (process.env.NO_LOG_API_KEY_IDS || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
for (const id of noLogIdsFromEnv) {
noLogKeys.add(id);
}
export function setNoLog(apiKeyId: string, noLog: boolean): void {
if (noLog) {
noLogKeys.add(apiKeyId);
} else {
noLogKeys.delete(apiKeyId);
}
noLogDbCache.set(apiKeyId, { value: noLog, timestamp: Date.now() });
}
function ensureNoLogColumn(db: SqliteAdapter): boolean {
if (noLogColumnVerified) {
return hasNoLogColumn;
}
try {
const columns = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>;
hasNoLogColumn = columns.some((column) => column.name === "no_log");
} catch {
hasNoLogColumn = false;
}
noLogColumnVerified = true;
return hasNoLogColumn;
}
function readNoLogFromDb(apiKeyId: string): boolean {
const db = getDb();
if (!db || !apiKeyId) return false;
if (!ensureNoLogColumn(db)) return false;
const cached = noLogDbCache.get(apiKeyId);
if (cached && Date.now() - cached.timestamp < NO_LOG_CACHE_TTL_MS) {
return cached.value;
}
try {
const row = db.prepare("SELECT no_log FROM api_keys WHERE id = ?").get(apiKeyId) as
| { no_log?: number }
| undefined;
const value = Boolean(row && Number(row.no_log) === 1);
noLogDbCache.set(apiKeyId, { value, timestamp: Date.now() });
return value;
} catch {
return false;
}
}
export function isNoLog(apiKeyId: string): boolean {
if (!apiKeyId) return false;
if (noLogKeys.has(apiKeyId)) return true;
const persistedNoLog = readNoLogFromDb(apiKeyId);
if (persistedNoLog) {
noLogKeys.add(apiKeyId);
}
return persistedNoLog;
}
+98
View File
@@ -0,0 +1,98 @@
type JsonRecord = Record<string, unknown>;
const WARNING_PATTERNS = [
/\[sanitizer\]/i,
/prompt injection detected/i,
/content(?:\s+has\s+been|\s+was)?\s+filtered/i,
/safety filter/i,
/policy violation/i,
] as const;
const WARNING_KEY_PATTERN = /warning/i;
const MAX_WARNING_HITS = 5;
const MAX_WARNING_DEPTH = 6;
const MAX_WARNING_LENGTH = 400;
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function truncateWarning(value: string) {
const compact = value.replace(/\s+/g, " ").trim();
return compact.length > MAX_WARNING_LENGTH
? `${compact.slice(0, MAX_WARNING_LENGTH)}...`
: compact;
}
function matchesProviderWarning(value: string, keyHint?: string) {
if (!value) return false;
if (WARNING_PATTERNS.some((pattern) => pattern.test(value))) return true;
return Boolean(keyHint && WARNING_KEY_PATTERN.test(keyHint));
}
function collectProviderWarnings(value: unknown, hits: Set<string>, keyHint?: string, depth = 0) {
if (
hits.size >= MAX_WARNING_HITS ||
depth >= MAX_WARNING_DEPTH ||
value === null ||
value === undefined
) {
return;
}
if (typeof value === "string") {
if (matchesProviderWarning(value, keyHint)) {
hits.add(truncateWarning(value));
}
return;
}
if (Array.isArray(value)) {
value.forEach((item) => collectProviderWarnings(item, hits, keyHint, depth + 1));
return;
}
if (typeof value !== "object") {
return;
}
for (const [key, entryValue] of Object.entries(value as JsonRecord)) {
collectProviderWarnings(entryValue, hits, key, depth + 1);
if (hits.size >= MAX_WARNING_HITS) {
break;
}
}
}
export function summarizeProviderConnectionForAudit(connection: unknown) {
const record = toRecord(connection);
if (Object.keys(record).length === 0) return null;
const sanitized: JsonRecord = { ...record };
delete sanitized.apiKey;
delete sanitized.accessToken;
delete sanitized.refreshToken;
delete sanitized.idToken;
const providerSpecificData = toRecord(record.providerSpecificData);
if (Object.keys(providerSpecificData).length > 0) {
const sanitizedProviderSpecificData = { ...providerSpecificData };
delete sanitizedProviderSpecificData.consoleApiKey;
sanitized.providerSpecificData = sanitizedProviderSpecificData;
}
return sanitized;
}
export function getProviderAuditTarget(connection: unknown) {
const record = toRecord(connection);
const provider = typeof record.provider === "string" ? record.provider : null;
const name = typeof record.name === "string" ? record.name : null;
const id = typeof record.id === "string" ? record.id : null;
return [provider, name || id].filter(Boolean).join(":") || "provider-connection";
}
export function extractProviderWarnings(...payloads: unknown[]) {
const hits = new Set<string>();
payloads.forEach((payload) => collectProviderWarnings(payload, hits));
return Array.from(hits);
}
+43
View File
@@ -0,0 +1,43 @@
import { getExecutor } from "@omniroute/open-sse/executors/index";
import type { ExecuteInput, ProviderCredentials } from "@omniroute/open-sse/executors/base";
import type {
ChatTurn,
ModelCallResult,
ModelClient,
} from "@omniroute/open-sse/services/compression/eval/types";
import { calculateCost } from "@/lib/usage/costCalculator";
/**
* Cost-aware judge ModelClient for the compression playground's fidelity verify.
* Hard Rule #18 — NOT unit-tested (touches the real executor); the cost math is calculateCost
* (already covered) and the cap logic is judgeFidelityBatch (unit-tested with a stub). Computes
* usdCost from FULL usage (prompt + completion tokens) via the canonical pricing engine, so the
* USD cap actually engages and totalUsd is real.
*/
export function createPricedJudgeClient(
provider: string,
credentials: ProviderCredentials
): ModelClient {
const executor = getExecutor(provider);
return {
async complete(model: string, messages: ChatTurn[]): Promise<ModelCallResult> {
const input: ExecuteInput = {
model,
body: { model, messages, stream: false },
stream: false,
credentials,
};
const raw = (await executor.execute(input)) as { response: Response };
const json = (await raw.response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const text = json.choices?.[0]?.message?.content ?? "";
const usdCost = await calculateCost(provider, model, {
prompt_tokens: json.usage?.prompt_tokens,
completion_tokens: json.usage?.completion_tokens,
});
return usdCost > 0 ? { text, usdCost } : { text };
},
};
}
+115
View File
@@ -0,0 +1,115 @@
import fs from "node:fs";
import path from "node:path";
import { SQLITE_FILE } from "@/lib/db/core";
import { getSettings } from "@/lib/db/settings";
import { applyRuntimeSettings, type RuntimeReloadChange } from "./runtimeSettings";
const DEFAULT_POLL_INTERVAL_MS = 5_000;
const MIN_POLL_INTERVAL_MS = 1_000;
let activeCheck: Promise<void> | null = null;
let queuedSource: string | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let sqliteWatcher: fs.FSWatcher | null = null;
function getPollIntervalMs() {
const parsed = Number.parseInt(process.env.OMNIROUTE_CONFIG_HOT_RELOAD_MS || "", 10);
if (!Number.isFinite(parsed) || parsed < MIN_POLL_INTERVAL_MS) {
return DEFAULT_POLL_INTERVAL_MS;
}
return parsed;
}
function isRelevantSqliteChange(filename: string | null): boolean {
if (!filename || !SQLITE_FILE) return false;
const baseName = path.basename(SQLITE_FILE);
return filename === baseName || filename.startsWith(`${baseName}-`);
}
function logChanges(source: string, changes: RuntimeReloadChange[]) {
if (changes.length === 0) return;
console.log(
`[HOT_RELOAD] source=${source} reloaded sections: ${changes
.map((entry) => entry.section)
.join(", ")}`
);
}
async function runHotReloadCheck(source: string) {
const settings = await getSettings();
const changes = await applyRuntimeSettings(settings, { source });
logChanges(source, changes);
}
function queueHotReloadCheck(source: string) {
if (activeCheck) {
queuedSource = source;
return;
}
activeCheck = runHotReloadCheck(source)
.catch((error) => {
console.warn(
`[HOT_RELOAD] Runtime config reload failed for ${source}:`,
error instanceof Error ? error.message : error
);
})
.finally(() => {
activeCheck = null;
if (!queuedSource) return;
const nextSource = queuedSource;
queuedSource = null;
queueHotReloadCheck(nextSource);
});
}
export function startRuntimeConfigHotReload(options: { pollIntervalMs?: number } = {}) {
if (pollTimer || sqliteWatcher) return;
const pollIntervalMs = Math.max(
options.pollIntervalMs || getPollIntervalMs(),
MIN_POLL_INTERVAL_MS
);
pollTimer = setInterval(() => {
queueHotReloadCheck("hot-reload:poll");
}, pollIntervalMs);
if (typeof pollTimer === "object" && "unref" in pollTimer) {
(pollTimer as { unref?: () => void }).unref?.();
}
if (SQLITE_FILE) {
try {
sqliteWatcher = fs.watch(path.dirname(SQLITE_FILE), (_eventType, filename) => {
const normalizedFilename = typeof filename === "string" ? filename : filename?.toString();
if (isRelevantSqliteChange(normalizedFilename || null)) {
queueHotReloadCheck("hot-reload:fs-watch");
}
});
} catch (error) {
console.warn(
"[HOT_RELOAD] SQLite file watch unavailable, polling only:",
error instanceof Error ? error.message : error
);
}
}
console.log(
`[HOT_RELOAD] Runtime config hot-reload started (poll=${pollIntervalMs}ms, fsWatch=${
sqliteWatcher ? "on" : "off"
})`
);
queueHotReloadCheck("hot-reload:start");
}
export function stopRuntimeConfigHotReloadForTests() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
sqliteWatcher?.close();
sqliteWatcher = null;
queuedSource = null;
activeCheck = null;
}
+551
View File
@@ -0,0 +1,551 @@
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import { setCustomBannedSignals } from "@omniroute/open-sse/services/accountFallback.ts";
type JsonRecord = Record<string, unknown>;
export type RuntimeReloadSection =
| "payloadRules"
| "modelAliases"
| "backgroundDegradation"
| "cliCompatProviders"
| "cacheControl"
| "usageTracking"
| "healthCheckLogs"
| "thoughtSignature"
| "modelsDevSync"
| "corsOrigins"
| "ccBridgeTransforms"
| "systemTransforms"
| "authzBypass"
| "bannedSignals";
export interface RuntimeReloadChange {
section: RuntimeReloadSection;
source: string;
}
interface AuthzBypassSnapshot {
enabled: boolean;
prefixes: string[];
}
interface RuntimeSettingsSnapshot {
payloadRules: unknown;
modelAliases: Record<string, string>;
backgroundDegradation: JsonRecord | null;
cliCompatProviders: string[];
alwaysPreserveClientCache: string;
antigravitySignatureCacheMode: string;
usageTokenBuffer: unknown;
hideHealthCheckLogs: boolean;
modelsDevSyncEnabled: boolean;
modelsDevSyncInterval: number | null;
corsOrigins: string;
ccBridgeTransforms: unknown;
systemTransforms: unknown;
authzBypass: AuthzBypassSnapshot;
customBannedSignals: string[];
}
// Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the
// pre-T-011 compile-time constant so the route guard works identically before
// the first `applyRuntimeSettings` call (e.g. cold-boot requests).
const DEFAULT_AUTHZ_BYPASS_SNAPSHOT: AuthzBypassSnapshot = {
enabled: true,
prefixes: ["/api/mcp/"],
};
const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
payloadRules: null,
modelAliases: {},
backgroundDegradation: null,
cliCompatProviders: [],
alwaysPreserveClientCache: "auto",
antigravitySignatureCacheMode: "enabled",
usageTokenBuffer: null,
hideHealthCheckLogs: false,
modelsDevSyncEnabled: false,
modelsDevSyncInterval: null,
corsOrigins: "",
ccBridgeTransforms: null,
systemTransforms: null,
authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT,
customBannedSignals: [],
};
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
// Module-local mirror of the current bypass policy. Read by the route guard
// on every non-loopback hit to a LOCAL_ONLY path via `getAuthzBypassSnapshot`.
// Initialised to the default so cold-boot requests (before any
// `applyRuntimeSettings` call) behave identically to PR #2473.
let currentAuthzBypass: AuthzBypassSnapshot = DEFAULT_AUTHZ_BYPASS_SNAPSHOT;
function isTruthyEnvFlag(value: string | undefined): boolean {
if (typeof value !== "string") return false;
return new Set(["1", "true", "yes", "on"]).has(value.trim().toLowerCase());
}
function isAutomatedTestProcess(): boolean {
return (
typeof process !== "undefined" &&
(process.env.NODE_ENV === "test" ||
process.env.VITEST !== undefined ||
process.argv.some((arg) => arg.includes("test")))
);
}
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function stableSerialize(value: unknown): string {
return JSON.stringify(canonicalize(value));
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => canonicalize(entry));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value as JsonRecord)
.sort((left, right) => left.localeCompare(right))
.map((key) => [key, canonicalize((value as JsonRecord)[key])])
);
}
return value;
}
function parseStoredJson(value: unknown, field: string): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch (error) {
console.warn(
`[HOT_RELOAD] Failed to parse persisted settings field "${field}":`,
error instanceof Error ? error.message : error
);
return null;
}
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter((entry) => entry.length > 0)
)
);
}
function normalizeStringRecord(value: unknown): Record<string, string> {
const record = toRecord(parseStoredJson(value, "modelAliases"));
const entries = Object.entries(record)
.map(([key, entryValue]) => [
key.trim(),
typeof entryValue === "string" ? entryValue.trim() : "",
])
.filter(([key, entryValue]) => key.length > 0 && entryValue.length > 0);
return Object.fromEntries(entries);
}
function normalizeBackgroundDegradation(value: unknown): JsonRecord | null {
const record = toRecord(parseStoredJson(value, "backgroundDegradation"));
if (Object.keys(record).length === 0) return null;
const degradationMap = Object.fromEntries(
Object.entries(toRecord(record.degradationMap))
.map(([key, entryValue]) => [
key.trim(),
typeof entryValue === "string" ? entryValue.trim() : "",
])
.filter(([key, entryValue]) => key.length > 0 && entryValue.length > 0)
);
const detectionPatterns = normalizeStringArray(record.detectionPatterns);
return {
enabled: record.enabled === true,
degradationMap,
detectionPatterns,
};
}
function normalizeNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizePayloadRules(value: unknown): unknown {
return parseStoredJson(value, "payloadRules");
}
function normalizeAuthzBypass(settings: Record<string, unknown>): AuthzBypassSnapshot {
const enabled =
settings.localOnlyManageScopeBypassEnabled === false
? false
: settings.localOnlyManageScopeBypassEnabled === true
? true
: DEFAULT_AUTHZ_BYPASS_SNAPSHOT.enabled;
const rawPrefixes = settings.localOnlyManageScopeBypassPrefixes;
const prefixes = Array.isArray(rawPrefixes)
? Array.from(
new Set(
rawPrefixes
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter((entry) => entry.length > 0 && entry.startsWith("/"))
)
)
: [...DEFAULT_AUTHZ_BYPASS_SNAPSHOT.prefixes];
return { enabled, prefixes };
}
/**
* O(1) accessor for the current LOCAL_ONLY manage-scope bypass policy.
*
* Consumed by the route-guard hot path (`isLocalOnlyBypassableByManageScope`).
* Returns the default snapshot (`{ enabled: true, prefixes: ["/api/mcp/"] }`)
* before the first `applyRuntimeSettings` call so cold-boot requests behave
* identically to PR #2473. Mutated only by `applyAuthzBypassSection`.
*
* Hot-reload latency: <50 ms (no I/O, no async, pure read of module-local
* state). Spec §Non-Functional Requirements / Performance.
*/
export function getAuthzBypassSnapshot(): AuthzBypassSnapshot {
return currentAuthzBypass;
}
export function buildRuntimeSettingsSnapshot(
settings: Record<string, unknown>
): RuntimeSettingsSnapshot {
return {
payloadRules: normalizePayloadRules(settings.payloadRules),
modelAliases: normalizeStringRecord(settings.modelAliases),
backgroundDegradation: normalizeBackgroundDegradation(settings.backgroundDegradation),
cliCompatProviders: normalizeStringArray(settings.cliCompatProviders),
alwaysPreserveClientCache:
typeof settings.alwaysPreserveClientCache === "string"
? settings.alwaysPreserveClientCache
: DEFAULT_RUNTIME_SETTINGS_SNAPSHOT.alwaysPreserveClientCache,
antigravitySignatureCacheMode:
typeof settings.antigravitySignatureCacheMode === "string"
? settings.antigravitySignatureCacheMode
: DEFAULT_RUNTIME_SETTINGS_SNAPSHOT.antigravitySignatureCacheMode,
usageTokenBuffer: settings.usageTokenBuffer ?? null,
hideHealthCheckLogs: settings.hideHealthCheckLogs === true,
modelsDevSyncEnabled: settings.modelsDevSyncEnabled === true,
modelsDevSyncInterval: normalizeNumber(settings.modelsDevSyncInterval),
corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "",
ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"),
systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"),
authzBypass: normalizeAuthzBypass(settings),
customBannedSignals: normalizeStringArray(settings.customBannedSignals),
};
}
function getPreviousSnapshot(): RuntimeSettingsSnapshot {
return lastAppliedSnapshot || DEFAULT_RUNTIME_SETTINGS_SNAPSHOT;
}
async function applyPayloadRulesSection(payloadRules: unknown) {
const { clearPayloadRulesConfigOverride, setPayloadRulesConfig } =
await import("@omniroute/open-sse/services/payloadRules.ts");
if (payloadRules === null || payloadRules === undefined) {
clearPayloadRulesConfigOverride();
return;
}
setPayloadRulesConfig(payloadRules);
}
async function applyModelAliasesSection(modelAliases: Record<string, string>) {
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
setCustomAliases(modelAliases);
}
async function applyBackgroundDegradationSection(backgroundDegradation: JsonRecord | null) {
const { getDefaultDegradationMap, getDefaultDetectionPatterns, setBackgroundDegradationConfig } =
await import("@omniroute/open-sse/services/backgroundTaskDetector.ts");
if (!backgroundDegradation) {
setBackgroundDegradationConfig({
enabled: false,
degradationMap: getDefaultDegradationMap(),
detectionPatterns: getDefaultDetectionPatterns(),
});
return;
}
setBackgroundDegradationConfig({
enabled: backgroundDegradation.enabled === true,
degradationMap: {
...getDefaultDegradationMap(),
...normalizeStringRecord(backgroundDegradation.degradationMap),
},
detectionPatterns:
normalizeStringArray(backgroundDegradation.detectionPatterns).length > 0
? normalizeStringArray(backgroundDegradation.detectionPatterns)
: getDefaultDetectionPatterns(),
});
}
async function applyCliCompatProvidersSection(cliCompatProviders: string[]) {
const { setCliCompatProviders } = await import("@omniroute/open-sse/config/cliFingerprints");
setCliCompatProviders(cliCompatProviders);
}
async function applyCacheControlSection() {
const { invalidateCacheControlSettingsCache } = await import("@/lib/cacheControlSettings");
invalidateCacheControlSettingsCache();
}
async function applyUsageTrackingSection(newBuffer: number | null) {
const { invalidateBufferTokensCache, setBufferTokensCache } =
await import("@omniroute/open-sse/utils/usageTracking.ts");
if (typeof newBuffer === "number" && newBuffer >= 0) {
// Set the value directly so the first request after a settings save gets the
// correct count synchronously — no race window back to DEFAULT (2000).
setBufferTokensCache(newBuffer);
} else {
invalidateBufferTokensCache();
}
}
async function applyThoughtSignatureSection(mode: string) {
const { setGeminiThoughtSignatureMode } =
await import("@omniroute/open-sse/services/geminiThoughtSignatureStore.ts");
setGeminiThoughtSignatureMode(mode);
}
async function applyCorsOriginsSection(corsOrigins: string) {
const { setRuntimeAllowedOrigins } = await import("@/server/cors/origins");
setRuntimeAllowedOrigins(corsOrigins);
}
/**
* Legacy alias for the v2 systemTransforms config. The `ccBridgeTransforms`
* settings field carried the single-provider shape `{ enabled, pipeline }`
* during Phase 2 (commit e3e962db, pre-release). v2 unifies everything under
* `systemTransforms.providers[*]`. We migrate the legacy shape into the v2
* registry on every reload so users with persisted Phase-2 data keep working.
*
* `setSystemTransformsConfig` accepts both shapes and routes legacy into
* `providers[PROVIDER_CC_BRIDGE]`.
*/
async function applyCcBridgeTransformsSection(ccBridgeTransforms: unknown) {
const { setSystemTransformsConfig } =
await import("@omniroute/open-sse/services/systemTransforms.ts");
if (ccBridgeTransforms && typeof ccBridgeTransforms === "object") {
setSystemTransformsConfig(ccBridgeTransforms);
}
}
/**
* Swap the in-process bypass policy. Synchronous, O(1), no I/O — the SLA
* (<50 ms hot-reload) is structurally satisfied by this shape.
*/
function applyAuthzBypassSection(snapshot: AuthzBypassSnapshot) {
currentAuthzBypass = { enabled: snapshot.enabled, prefixes: [...snapshot.prefixes] };
}
async function applySystemTransformsSection(systemTransforms: unknown) {
const { setSystemTransformsConfig, resetSystemTransformsConfig } =
await import("@omniroute/open-sse/services/systemTransforms.ts");
if (
systemTransforms === null ||
systemTransforms === undefined ||
typeof systemTransforms !== "object"
) {
resetSystemTransformsConfig();
return;
}
setSystemTransformsConfig(systemTransforms);
}
async function applyModelsDevSyncSection(
previousSnapshot: RuntimeSettingsSnapshot,
currentSnapshot: RuntimeSettingsSnapshot,
force: boolean
) {
const { startPeriodicSync, stopPeriodicSync } = await import("@/lib/modelsDevSync");
const skipBackgroundSyncInTests =
(isAutomatedTestProcess() && process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS !== "1") ||
isTruthyEnvFlag(process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES);
if (skipBackgroundSyncInTests) {
stopPeriodicSync();
return;
}
const wasEnabled = previousSnapshot.modelsDevSyncEnabled === true;
const isEnabled = currentSnapshot.modelsDevSyncEnabled === true;
const intervalChanged =
previousSnapshot.modelsDevSyncInterval !== currentSnapshot.modelsDevSyncInterval;
if (!isEnabled) {
if (wasEnabled || force) {
stopPeriodicSync();
}
return;
}
if (force) {
stopPeriodicSync();
startPeriodicSync(currentSnapshot.modelsDevSyncInterval || undefined);
return;
}
if (!wasEnabled) {
startPeriodicSync(currentSnapshot.modelsDevSyncInterval || undefined);
return;
}
if (intervalChanged) {
stopPeriodicSync();
startPeriodicSync(currentSnapshot.modelsDevSyncInterval || undefined);
}
}
export async function applyRuntimeSettings(
settings: Record<string, unknown>,
options: { force?: boolean; source?: string } = {}
): Promise<RuntimeReloadChange[]> {
const source = options.source || "runtime";
const force = options.force === true;
const hasBootstrappedSnapshot = lastAppliedSnapshot !== null;
const currentSnapshot = buildRuntimeSettingsSnapshot(settings);
const previousSnapshot = getPreviousSnapshot();
const changes: RuntimeReloadChange[] = [];
const markChanged = (section: RuntimeReloadSection) => {
changes.push({ section, source });
};
const hasChanged = <T>(currentValue: T, previousValue: T) =>
stableSerialize(currentValue) !== stableSerialize(previousValue);
if (force || hasChanged(currentSnapshot.payloadRules, previousSnapshot.payloadRules)) {
await applyPayloadRulesSection(currentSnapshot.payloadRules);
markChanged("payloadRules");
}
if (force || hasChanged(currentSnapshot.modelAliases, previousSnapshot.modelAliases)) {
await applyModelAliasesSection(currentSnapshot.modelAliases);
markChanged("modelAliases");
}
if (
force ||
hasChanged(currentSnapshot.backgroundDegradation, previousSnapshot.backgroundDegradation)
) {
await applyBackgroundDegradationSection(currentSnapshot.backgroundDegradation);
markChanged("backgroundDegradation");
}
if (
force ||
hasChanged(currentSnapshot.cliCompatProviders, previousSnapshot.cliCompatProviders)
) {
await applyCliCompatProvidersSection(currentSnapshot.cliCompatProviders);
markChanged("cliCompatProviders");
}
if (
force ||
hasChanged(
currentSnapshot.alwaysPreserveClientCache,
previousSnapshot.alwaysPreserveClientCache
)
) {
await applyCacheControlSection();
markChanged("cacheControl");
}
if (force || hasChanged(currentSnapshot.usageTokenBuffer, previousSnapshot.usageTokenBuffer)) {
const newBuffer =
typeof currentSnapshot.usageTokenBuffer === "number"
? currentSnapshot.usageTokenBuffer
: null;
await applyUsageTrackingSection(newBuffer);
markChanged("usageTracking");
}
if (force || currentSnapshot.hideHealthCheckLogs !== previousSnapshot.hideHealthCheckLogs) {
clearHealthCheckLogCache();
markChanged("healthCheckLogs");
}
if (
force ||
hasChanged(
currentSnapshot.antigravitySignatureCacheMode,
previousSnapshot.antigravitySignatureCacheMode
)
) {
await applyThoughtSignatureSection(currentSnapshot.antigravitySignatureCacheMode);
markChanged("thoughtSignature");
}
if (
force ||
(hasBootstrappedSnapshot &&
(currentSnapshot.modelsDevSyncEnabled !== previousSnapshot.modelsDevSyncEnabled ||
currentSnapshot.modelsDevSyncInterval !== previousSnapshot.modelsDevSyncInterval))
) {
await applyModelsDevSyncSection(previousSnapshot, currentSnapshot, force);
markChanged("modelsDevSync");
}
if (force || hasChanged(currentSnapshot.corsOrigins, previousSnapshot.corsOrigins)) {
await applyCorsOriginsSection(currentSnapshot.corsOrigins);
markChanged("corsOrigins");
}
if (
force ||
hasChanged(currentSnapshot.ccBridgeTransforms, previousSnapshot.ccBridgeTransforms)
) {
await applyCcBridgeTransformsSection(currentSnapshot.ccBridgeTransforms);
markChanged("ccBridgeTransforms");
}
if (force || hasChanged(currentSnapshot.systemTransforms, previousSnapshot.systemTransforms)) {
await applySystemTransformsSection(currentSnapshot.systemTransforms);
markChanged("systemTransforms");
}
if (force || hasChanged(currentSnapshot.authzBypass, previousSnapshot.authzBypass)) {
applyAuthzBypassSection(currentSnapshot.authzBypass);
markChanged("authzBypass");
}
if (
force ||
hasChanged(currentSnapshot.customBannedSignals, previousSnapshot.customBannedSignals)
) {
setCustomBannedSignals(currentSnapshot.customBannedSignals);
markChanged("bannedSignals");
}
lastAppliedSnapshot = currentSnapshot;
return changes;
}
export function resetRuntimeSettingsStateForTests() {
lastAppliedSnapshot = null;
currentAuthzBypass = DEFAULT_AUTHZ_BYPASS_SNAPSHOT;
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Console Log Interceptor — captures console output to a log file.
*
* Monkey-patches console.log, console.info, console.warn, console.error,
* and console.debug to also append JSON log entries to a file. This allows
* the Console Log Viewer to display application logs in real-time.
*
* Call initConsoleInterceptor() once at server startup (before any logging).
*
* @module lib/consoleInterceptor
*/
import { appendFileSync, existsSync, mkdirSync } from "fs";
import { dirname, resolve } from "path";
import { getAppLogFilePath, getAppLogToFile } from "./logEnv";
const logToFile = getAppLogToFile();
const logFilePath = resolve(getAppLogFilePath());
declare global {
var __omnirouteConsoleInterceptorInit: boolean | undefined;
}
/**
* Map console method names to log levels.
*/
const LEVEL_MAP: Record<string, string> = {
debug: "debug",
log: "info",
info: "info",
warn: "warn",
error: "error",
};
/**
* Ensure the log directory exists.
*/
function ensureDir() {
const dir = dirname(logFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* Try to extract component name from message patterns like [COMPONENT] or [component].
*/
function extractComponent(msg: string): string {
const match = msg.match(/^\[([^\]]+)\]/);
return match ? match[1] : "app";
}
/**
* Convert arguments to a string message, handling objects and errors.
*/
function argsToMessage(args: unknown[]): string {
return args
.map((arg) => {
if (arg instanceof Error) return `${arg.message}\n${arg.stack || ""}`;
if (typeof arg === "object" && arg !== null) {
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
return String(arg);
})
.join(" ");
}
/**
* Append a JSON log entry to the log file.
*/
function writeEntry(level: string, args: unknown[]) {
try {
const message = argsToMessage(args);
const entry = {
timestamp: new Date().toISOString(),
level,
component: extractComponent(message),
message,
};
appendFileSync(logFilePath, JSON.stringify(entry) + "\n");
} catch {
// Silently fail — never break the app over log writing
}
}
function shouldIgnoreConsoleWriteError(error: unknown): boolean {
return error instanceof Error && (error as NodeJS.ErrnoException).code === "EPIPE";
}
/**
* Initialize the console interceptor.
* Patches console.log, console.info, console.warn, console.error, console.debug
* to also write to the log file.
*
* Safe to call multiple times — only initializes once.
*/
export function initConsoleInterceptor(): void {
if (!logToFile || globalThis.__omnirouteConsoleInterceptorInit) return;
try {
ensureDir();
} catch {
// Can't create log dir — skip interception
return;
}
globalThis.__omnirouteConsoleInterceptorInit = true;
// Save original methods
const originalMethods = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
debug: console.debug.bind(console),
};
// Patch each console method
for (const [method, level] of Object.entries(LEVEL_MAP)) {
const original = originalMethods[method as keyof typeof originalMethods];
if (!original) continue;
(console as unknown as Record<string, unknown>)[method] = (...args: unknown[]) => {
writeEntry(level, args);
try {
original(...args);
} catch (error) {
if (!shouldIgnoreConsoleWriteError(error)) throw error;
}
};
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Simple DI Container — Factory-pattern service locator
*
* Provides a lightweight dependency injection container using factory
* functions (no heavy frameworks). Services are lazily instantiated
* and cached as singletons.
*
* Usage:
* import { container } from '@/lib/container';
* const settings = container.resolve('settings');
*
* Registration:
* container.register('myService', () => new MyService());
*
* @module lib/container
*/
import { evaluateFirstAllowed, evaluateRequest, PolicyEngine } from "../domain/policyEngine.ts";
import { getDbInstance } from "./db/core.ts";
import {
decrypt,
decryptConnectionFields,
encrypt,
encryptConnectionFields,
} from "./db/encryption.ts";
import { getSettings } from "./localDb.ts";
import { getCircuitBreaker } from "../shared/utils/circuitBreaker.ts";
import { recordTelemetry, RequestTelemetry } from "../shared/utils/requestTelemetry.ts";
type Factory<T = any> = () => T;
class Container {
private _factories = new Map<string, Factory>();
private _instances = new Map<string, any>();
/**
* Register a factory for a service. Does NOT instantiate until resolve().
*/
register<T>(name: string, factory: Factory<T>): void {
this._factories.set(name, factory);
// Clear cached instance if re-registering (useful for testing)
this._instances.delete(name);
}
/**
* Resolve a service by name. Lazy-creates via factory on first call,
* then returns the cached singleton.
*/
resolve<T = any>(name: string): T {
if (this._instances.has(name)) {
return this._instances.get(name) as T;
}
const factory = this._factories.get(name);
if (!factory) {
throw new Error(`[Container] No factory registered for "${name}"`);
}
const instance = factory();
this._instances.set(name, instance);
return instance as T;
}
/**
* Check if a service is registered (factory exists).
*/
has(name: string): boolean {
return this._factories.has(name);
}
/**
* List all registered service names.
*/
list(): string[] {
return Array.from(this._factories.keys());
}
/**
* Reset all factories and instances (for testing).
*/
reset(): void {
this._factories.clear();
this._instances.clear();
}
}
// ── Singleton container instance ──
export const container = new Container();
// ── Default registrations ──
// Services are still lazily instantiated on first resolve().
container.register("settings", () => {
return { get: getSettings };
});
container.register("db", () => {
return getDbInstance();
});
container.register("encryption", () => {
return {
encrypt,
decrypt,
encryptConnectionFields,
decryptConnectionFields,
};
});
container.register("policyEngine", () => {
return { evaluateRequest, evaluateFirstAllowed, PolicyEngine };
});
container.register("circuitBreaker", () => {
return { get: getCircuitBreaker };
});
container.register("telemetry", () => {
return { RequestTelemetry, recordTelemetry };
});
export default container;
+147
View File
@@ -0,0 +1,147 @@
import { getAllSyncedAvailableModels } from "./db/models";
import { getResolvedModelCapabilities } from "./modelCapabilities";
import {
getModelContextOverrideRecord,
setModelContextOverride,
removeModelContextOverride,
} from "./db/modelContextOverrides";
/**
* Feature 5004 — self-correcting context-window reconciler.
*
* Compares each model's provider-declared window (captured by `/models` discovery in
* `syncedAvailableModels`) against the override-free catalog and, when they diverge,
* pins the discovered value as an `auto:discovery` override so the real window wins in
* `getModelContextLimit`. It never touches `manual` overrides, and it self-heals by
* removing a now-redundant auto override when the catalog has caught up.
*
* No new network fetch: it reconciles data the managed-model import already persisted,
* so it does not duplicate `modelsDevSync` / `modelDiscovery`.
*/
export interface DiscoveredWindow {
provider: string;
modelId: string;
window: number | null;
}
export interface ReconcileDeps {
getCatalogWindow: (provider: string, modelId: string) => number | null;
getExistingSource: (provider: string, modelId: string) => string | null;
writeAuto: (provider: string, modelId: string, window: number) => void;
removeOverride: (provider: string, modelId: string) => void;
}
export interface ReconcileResult {
scanned: number;
written: number;
removed: number;
skippedManual: number;
}
/**
* Pure reconcile: given the discovered windows and a set of injectable deps, decide
* which auto overrides to write/remove. Deterministic and side-effect-free except
* through the injected `writeAuto`/`removeOverride`.
*/
export function reconcileContextWindows(
discovered: DiscoveredWindow[],
deps: ReconcileDeps
): ReconcileResult {
const result: ReconcileResult = { scanned: 0, written: 0, removed: 0, skippedManual: 0 };
for (const { provider, modelId, window } of discovered) {
result.scanned++;
if (!provider || !modelId) continue;
if (typeof window !== "number" || !Number.isInteger(window) || window <= 0) continue;
const existingSource = deps.getExistingSource(provider, modelId);
if (existingSource === "manual") {
result.skippedManual++;
continue;
}
const catalog = deps.getCatalogWindow(provider, modelId);
if (window !== catalog) {
deps.writeAuto(provider, modelId, window);
result.written++;
} else if (existingSource) {
// Discovered window now matches the catalog and a stale auto override exists → drop it.
deps.removeOverride(provider, modelId);
result.removed++;
}
}
return result;
}
/** Flatten the per-provider discovery map into the reconcile input. */
function toDiscoveredWindows(
byProvider: Record<string, Array<{ id: string; inputTokenLimit?: number }>>
): DiscoveredWindow[] {
const out: DiscoveredWindow[] = [];
for (const [provider, models] of Object.entries(byProvider)) {
for (const m of models) {
out.push({ provider, modelId: m.id, window: m.inputTokenLimit ?? null });
}
}
return out;
}
/** Run the reconcile against the live DB (discovery → overrides). */
export async function runContextWindowReconcile(): Promise<ReconcileResult> {
const byProvider = await getAllSyncedAvailableModels();
const discovered = toDiscoveredWindows(byProvider);
return reconcileContextWindows(discovered, {
getCatalogWindow: (provider, modelId) =>
getResolvedModelCapabilities({ provider, model: modelId }).contextWindow,
getExistingSource: (provider, modelId) =>
getModelContextOverrideRecord(provider, modelId)?.source ?? null,
writeAuto: (provider, modelId, window) => {
setModelContextOverride(provider, modelId, window, "auto:discovery");
},
removeOverride: (provider, modelId) => {
removeModelContextOverride(provider, modelId);
},
});
}
// --- Periodic job (mirrors modelsDevSync.startPeriodicSync) ---
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
let reconcileTimer: ReturnType<typeof setInterval> | null = null;
function resolveIntervalMs(): number {
const raw = process.env.CONTEXT_WINDOW_RECONCILE_INTERVAL;
if (raw === undefined) return DEFAULT_INTERVAL_MS;
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) return 0; // 0/invalid → disabled
return Math.floor(seconds * 1000);
}
/**
* Start the periodic reconcile. Idempotent. Disabled when
* `CONTEXT_WINDOW_RECONCILE_INTERVAL=0`. Best-effort: failures are swallowed (the
* static catalog remains the source of truth).
*/
export function startContextWindowReconcile(intervalMs?: number): void {
if (reconcileTimer) return;
const interval = intervalMs ?? resolveIntervalMs();
if (!interval || interval <= 0) return;
const tick = () => {
void runContextWindowReconcile().catch(() => {
// Swallow — reconcile is advisory; the catalog still resolves windows.
});
};
// Initial non-blocking pass, then on the interval.
setTimeout(tick, 0);
reconcileTimer = setInterval(tick, interval);
reconcileTimer.unref?.();
}
export function stopContextWindowReconcile(): void {
if (reconcileTimer) {
clearInterval(reconcileTimer);
reconcileTimer = null;
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Copilot CodeGraph Knowledge Module
*
* Provides the Copilot with read-only access to the project's CodeGraph index.
* Queries the `.codegraph/codegraph.db` SQLite database to find symbols,
* explore relationships, list files, and search documentation.
*
* Falls back gracefully if the CodeGraph DB does not exist (e.g., production installs).
*/
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CodeGraphNode {
id: string;
kind: string;
name: string;
qualifiedName: string;
filePath: string;
language: string;
startLine: number;
endLine: number;
signature?: string;
docstring?: string;
isExported: boolean;
visibility?: string;
}
// ---------------------------------------------------------------------------
// Database access (lazy loaded)
// ---------------------------------------------------------------------------
let _db: unknown = null;
function getDbPath(): string | null {
// Try project root first (dev), then cwd, then DATA_DIR
const candidates = [
join(process.cwd(), ".codegraph", "codegraph.db"),
join(process.cwd(), "..", ".codegraph", "codegraph.db"),
];
// Try to resolve from the project root
const __dirname = dirname(fileURLToPath(import.meta.url));
// Walk up to find .codegraph/
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, ".codegraph", "codegraph.db");
if (existsSync(candidate)) return candidate;
const parent = join(dir, "..");
if (parent === dir) break;
dir = parent;
}
for (const c of candidates) {
if (existsSync(c)) return c;
}
return null;
}
export interface CodeGraphQueryResult {
success: boolean;
data: unknown;
error?: string;
engine: "sqlite" | "cli" | "none";
}
function queryDb(query: string, params: unknown[] = []): CodeGraphQueryResult {
try {
if (!_db) {
const dbPath = getDbPath();
if (!dbPath) {
return { success: false, data: null, error: "CodeGraph DB not found", engine: "none" };
}
// Dynamic import to avoid hard dependency on better-sqlite3
_db = null;
// Use better-sqlite3 if available
try {
const Database = require("better-sqlite3");
_db = new Database(dbPath, { readonly: true });
} catch {
return {
success: false,
data: null,
error: "better-sqlite3 not available",
engine: "none",
};
}
}
const stmt = (
_db as { prepare: (sql: string) => { all: (params: unknown[]) => unknown[] } }
).prepare(query);
const rows = stmt.all(params);
return { success: true, data: rows, engine: "sqlite" };
} catch (err) {
return {
success: false,
data: null,
error: err instanceof Error ? err.message : "Unknown error",
engine: "none",
};
}
}
// ---------------------------------------------------------------------------
// Search operations
// ---------------------------------------------------------------------------
/**
* Search symbols by name (exact or partial match via FTS).
*/
export function searchSymbols(query: string, limit = 20): CodeGraphQueryResult {
const sql = `
SELECT n.*
FROM nodes n
JOIN nodes_fts fts ON n.id = fts.id
WHERE nodes_fts MATCH ?
ORDER BY rank
LIMIT ?
`;
// Escape FTS special chars and create prefix query
const sanitized = query.replace(/[^a-zA-Z0-9_]/g, " ").trim();
if (!sanitized) {
// Fallback to LIKE if query is empty after sanitization
return queryDb(`SELECT * FROM nodes WHERE lower(name) LIKE ? ORDER BY kind, name LIMIT ?`, [
`%${query.toLowerCase()}%`,
limit,
]);
}
const ftsQuery = sanitized
.split(/\s+/)
.map((w) => `"${w}"*`)
.join(" AND ");
return queryDb(sql, [ftsQuery, limit]);
}
/**
* Find callers of a symbol (edges where target matches).
*/
export function findCallers(symbolName: string, limit = 20): CodeGraphQueryResult {
return queryDb(
`SELECT e.id as edgeId, e.kind as edgeKind, e.line, e.col,
s.id as sourceId, s.name as sourceName, s.kind as sourceKind,
s.file_path as sourceFile, s.start_line as sourceLine,
t.name as targetName, t.file_path as targetFile
FROM edges e
JOIN nodes s ON e.source = s.id
JOIN nodes t ON e.target = t.id
WHERE t.name = ?
ORDER BY e.kind
LIMIT ?`,
[symbolName, limit]
);
}
/**
* Find callees of a symbol (edges where source matches).
*/
export function findCallees(symbolName: string, limit = 20): CodeGraphQueryResult {
return queryDb(
`SELECT e.id as edgeId, e.kind as edgeKind, e.line, e.col,
s.name as sourceName,
t.id as targetId, t.name as targetName, t.kind as targetKind,
t.file_path as targetFile, t.start_line as targetLine
FROM edges e
JOIN nodes s ON e.source = s.id
JOIN nodes t ON e.target = t.id
WHERE s.name = ?
ORDER BY e.kind
LIMIT ?`,
[symbolName, limit]
);
}
/**
* Get context for a file: all symbols defined in it.
*/
export function getFileContext(filePath: string): CodeGraphQueryResult {
// Try matching on suffix of file_path (many nodes store paths relative to root)
return queryDb(
`SELECT * FROM nodes
WHERE file_path LIKE ? OR file_path = ?
ORDER BY start_line
LIMIT 100`,
[`%${filePath}`, filePath]
);
}
/**
* List all indexed files, optionally filtered by language.
*/
export function listFiles(language?: string, limit = 50): CodeGraphQueryResult {
if (language) {
return queryDb(`SELECT * FROM files WHERE language = ? ORDER BY path LIMIT ?`, [
language,
limit,
]);
}
return queryDb(`SELECT * FROM files ORDER BY path LIMIT ?`, [limit]);
}
/**
* Check if CodeGraph DB is available.
*/
export function isCodeGraphAvailable(): boolean {
return getDbPath() !== null;
}
/**
* Get summary stats from the index.
*/
export function getCodeGraphStats(): CodeGraphQueryResult {
return queryDb(`SELECT 'total_nodes' as key, COUNT(*) as value FROM nodes UNION ALL
SELECT 'total_edges', COUNT(*) FROM edges UNION ALL
SELECT 'total_files', COUNT(*) FROM files UNION ALL
SELECT 'languages', GROUP_CONCAT(DISTINCT language) FROM files UNION ALL
SELECT 'node_kinds', GROUP_CONCAT(DISTINCT kind) FROM nodes`);
}
+372
View File
@@ -0,0 +1,372 @@
/**
* OmniRoute Copilot — Chat Engine
*
* Processes user messages, classifies intent, executes tools,
* queries CodeGraph, invokes CLI commands, or responds with
* knowledge from the system prompt.
*/
import { getCopilotSystemPrompt } from "./systemPrompt";
import { COPILOT_TOOLS, getCopilotTool, getCopilotToolDescriptions } from "./tools";
// ── Types ────────────────────────────────────────────────────────────────────
export interface CopilotMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface CopilotRequest {
messages: CopilotMessage[];
}
export interface CopilotResponse {
message: string;
toolCalls?: Array<{ name: string; args: Record<string, unknown>; result: string }>;
}
// ── Tool Lookup for Dynamic Dispatch ────────────────────────────────────────
const TOOL_NAMES = COPILOT_TOOLS.map((t) => t.name);
// ── Knowledge-based responses ───────────────────────────────────────────────
function getKnowledgeResponse(query: string): string | null {
const q = query.toLowerCase();
// Architecture questions
if (
/architecture|arquitectura|pipeline/.test(q) ||
(q.includes("request") && (q.includes("flow") || q.includes("path")))
) {
return `## OmniRoute Architecture
The request pipeline flows through:
1. **API Route** → CORS → Zod validation → Auth (optional)
2. **Guardrails** → Prompt injection guard, PII masking
3. **Pre-request Middleware Hooks** (NEW) — mutate routing decisions
4. **Task-aware routing / Combo resolution** — picks the target
5. **Cache check** — semantic/signature cache
6. **Rate limit check**
7. **Request translation** — OpenAI format → provider format
8. **Executor** — build URL + headers, fetch with retry
9. **Response translation** — provider format → client format
10. **SSE stream or JSON response**
The data layer uses **SQLite** via 45+ domain modules in \`src/lib/db/\`.
The streaming engine lives in \`open-sse/\` (handlers, executors, translator).`;
}
// Combo questions
if (/combo|routing|strategy|estrategia/.test(q)) {
return `## Combo Routing
Combos chain multiple targets (provider+model) with a strategy:
**14 strategies available:**
- \`priority\`: Try targets in order, fall through on failure
- \`weighted\`: Distribute load by weight
- \`round-robin\`: Cycle through targets
- \`auto\`: Intelligent selection (rules, cost, latency, eco, fast, LKGP)
- \`fill-first\`: Fill capacity of first target
- \`cost-optimized\`: Minimize cost
- \`context-optimized\`: Maximize context window
- \`p2c\`: Power of Two Choices
- \`random\` / \`strict-random\`: Random selection
- \`least-used\`: Load balance by usage
- \`reset-aware\`: Account for API reset windows
- \`context-relay\`: Relay context between models
- \`lkgp\`: Last Known Good Provider
Use \`createCombo\` tool or \`runOmniRouteCli\` to create them.`;
}
// Provider questions
if (/provider|proveedor/.test(q)) {
return `## Providers (212+)
OmniRoute supports 212+ providers across categories:
- **Free**: Qoder AI, Qwen Code, Kiro AI
- **OAuth** (14): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Windsurf, etc.
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, etc.
- **Self-Hosted** (8+): LM Studio, vLLM, Ollama, Triton, etc.
- **Custom**: \`openai-compatible-*\` and \`anthropic-compatible-*\`
Use \`listProviders\` to see your configured ones.`;
}
// Debugging/troubleshooting
if (/debug|error|fail|fallo|problema|issue|crash|log/.test(q)) {
return `## Troubleshooting
**Common issues:**
1. **Provider returns errors**: Check credentials with the health API (\`/api/monitoring/health\`)
2. **Combo targeting wrong provider**: Check strategy and targets with \`listCombos\`
3. **Rate limiting**: Check circuit breaker state via health API
4. **Auth errors**: Verify API key scopes with \`listApiKeys\`
5. **DeepSeek 400 errors**: Likely \`reasoning_content\` stripping issue — fixed in schemaCoercion.ts
**Use CodeGraph** to investigate specific code paths with \`searchCodeGraph\`.`;
}
// CodeGraph questions
if (/codigo|código|codebase|cómo funciona|how does|where is|dónde está/.test(q)) {
return `## Codebase Investigation
I can use CodeGraph to explore the OmniRoute codebase. Just ask me:
- "Busca la función handleChatCore"
- "Quién llama a sanitizeMessage?"
- "Qué funciones hay en combo.ts?"
- "Dame contexto del archivo chatCore.ts"
- "Lista los archivos TypeScript indexados"
Use these search terms naturally and I'll query the CodeGraph index.`;
}
return null;
}
// ── Intent Classification ────────────────────────────────────────────────────
const INTENT_PATTERNS: Array<{
pattern: RegExp;
tool: string;
extractArgs: (match: RegExpMatchArray) => Record<string, unknown>;
}> = [
// ── Provider tools ──
{
pattern: /list.*(?:providers?|connections?|accounts)/i,
tool: "listProviders",
extractArgs: () => ({}),
},
{
pattern: /list.*(oauth|api.?key|free|local).*provider/i,
tool: "listProviders",
extractArgs: (m) => ({ type: (m[1] || "").toLowerCase().replace(/[^a-z]/g, "") }),
},
// ── Combo tools ──
{ pattern: /list.*(?:combo|route)/i, tool: "listCombos", extractArgs: () => ({}) },
{ pattern: /show.*(?:combo|route)/i, tool: "listCombos", extractArgs: () => ({}) },
{ pattern: /qu[eé].*combo/i, tool: "listCombos", extractArgs: () => ({}) },
// Create combo
{
pattern: /crea(?:te|r?)\s*(?:un\s*)?combo/i,
tool: "createCombo",
extractArgs: () => ({}),
},
// ── API Key tools ──
{ pattern: /list.*(?:api.?key|key)/i, tool: "listApiKeys", extractArgs: () => ({}) },
{ pattern: /show.*(?:api.?key|key)/i, tool: "listApiKeys", extractArgs: () => ({}) },
{
pattern: /crea(?:te|r?)\s*(?:un\s*)?(?:api.?)?key/i,
tool: "createApiKey",
extractArgs: () => ({}),
},
{ pattern: /revoke|revocar|borrar.*key/i, tool: "revokeApiKey", extractArgs: () => ({}) },
// ── Key Group tools ──
{ pattern: /list.*(?:group|grupo)/i, tool: "listKeyGroups", extractArgs: () => ({}) },
{ pattern: /show.*(?:group|grupo)/i, tool: "listKeyGroups", extractArgs: () => ({}) },
// ── CodeGraph tools ──
// Search symbols
{
pattern:
/(?:busca|search|find|dónde está|where is)\s*(?:el\s*)?(?:símbolo|symbol|function|función|class|clase)?\s*[`"']?([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)[`"']?/i,
tool: "searchCodeGraph",
extractArgs: (m) => ({ query: m[1] }),
},
// Callers
{
pattern:
/(?:qui[ée]n|who|what)\s*(?:llama|call|usa|use|referenc).*[`"']?([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)[`"']?/i,
tool: "findCallers",
extractArgs: (m) => ({ symbol: m[1] }),
},
// Callees
{
pattern:
/(?:qué|what|que)\s*(?:llama|call|usa)\s*[`"']?([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)[`"']?/i,
tool: "findCallees",
extractArgs: (m) => ({ symbol: m[1] }),
},
// File context
{
pattern:
/(?:contexto|context|símbolos|symbols|funciones|functions)\s*(?:de|in|del|en)\s*[`"']?([a-zA-Z0-9_/.-]+(?:\.\w+)?)[`"']?/i,
tool: "getFileContext",
extractArgs: (m) => ({ filePath: m[1] }),
},
// Files
{
pattern: /list.*(?:archivos|files|index)/i,
tool: "listCodeGraphFiles",
extractArgs: () => ({}),
},
{ pattern: /codegraph.*(?:stats|stat|status)/i, tool: "codeGraphStats", extractArgs: () => ({}) },
// ── CLI executor ──
{
pattern: /^(?:cli|terminal|ejecuta|run|exec)\s+(.+)/i,
tool: "runOmniRouteCli",
extractArgs: (m) => ({ command: m[1].trim() }),
},
// ── Health / status ──
{
pattern: /^(?:health|status|salud|estado)$/i,
tool: "runOmniRouteCli",
extractArgs: () => ({ command: "health" }),
},
// ── Help ──
{
pattern: /^(?:help|ayuda|que puedes hacer|qué puedes hacer|\?)$/i,
tool: "help",
extractArgs: () => ({}),
},
];
function classifyIntent(text: string): { tool: string; args: Record<string, unknown> } | null {
for (const intent of INTENT_PATTERNS) {
const match = text.match(intent.pattern);
if (match) {
return { tool: intent.tool, args: intent.extractArgs(match) };
}
}
return null;
}
// ── Help Response ────────────────────────────────────────────────────────────
function getHelpResponse(): string {
return `## OmniRoute Copilot — Comandos disponibles
### Configuración
- "Lista los providers" → \`listProviders\`
- "Lista mis combos" → \`listCombos\`
- "Crea un combo..." → \`createCombo\` (te pediré detalles)
- "Lista las API keys" → \`listApiKeys\`
- "Crea una API key para desarrollo" → \`createApiKey\`
- "Revoca la key abc123" → \`revokeApiKey\`
- "Lista los grupos" → \`listKeyGroups\`
### CodeGraph (investigar el código)
- "Busca la función handleChatCore" → \`searchCodeGraph\`
- "Quién llama a sanitizeMessage?" → \`findCallers\`
- "Qué funciones hay en combo.ts?" → \`getFileContext\`
- "Lista los archivos indexados" → \`listCodeGraphFiles\`
### CLI
- "CLI health" → ejecuta \`omniroute health\`
- "CLI list-combos" → ejecuta \`omniroute list-combos\`
- "CLI set-budget 10" → ejecuta \`omniroute set-budget 10\`
### Conocimiento
- "Cómo funciona OmniRoute?" → explica la arquitectura
- "Qué son los combos?" → explica routing
- "Cómo debuggeo un error?" → troubleshooting
### Tools disponibles:\n\n${getCopilotToolDescriptions()}`;
}
// ── Chat Engine ──────────────────────────────────────────────────────────────
export async function processCopilotChat(request: CopilotRequest): Promise<CopilotResponse> {
const lastMessage = request.messages[request.messages.length - 1];
if (!lastMessage || lastMessage.role !== "user") {
return { message: "No user message found." };
}
const userText = lastMessage.content.trim();
if (!userText) {
return { message: "Please provide a message." };
}
// Classify intent
const intent = classifyIntent(userText);
if (!intent) {
// No tool match — check knowledge base
const knowledge = getKnowledgeResponse(userText);
if (knowledge) {
return { message: knowledge };
}
// Fallback: respond with help
return {
message: `I understand you want help with OmniRoute.\n\n${getHelpResponse()}`,
};
}
// Handle help separately
if (intent.tool === "help") {
return { message: getHelpResponse() };
}
// Handle tools that need more info from the user
if (intent.tool === "createCombo" && !userText.includes("{") && !userText.includes("target")) {
return {
message: `Para crear un combo, necesito algunos detalles:
1. **Nombre** del combo (ej: "mi-combo-fallback")
2. **Estrategia** (priority, weighted, round-robin, cost-optimized, auto)
3. **Targets** — los proveedores/modelos en orden
Puedes decirme algo como:
> Crea un combo llamado "fallback-claude" con estrategia priority y targets: [{"provider":"claude-code","model":"claude-sonnet-4"},{"provider":"openai","model":"gpt-4o"}]`,
};
}
// Handle createApiKey — extract name from sentence
if (intent.tool === "createApiKey") {
const nameMatch = userText.match(
/(?:llamad[oa]|named?|par[ae]?)\s*["'']?([a-zA-Z0-9_-]+)["'']?/i
);
const name = nameMatch ? nameMatch[1] : "copilot-key";
const scopeMatch = userText.match(/(?:con\s*)?scope?s?\s*:?\s*["'']?([a-zA-Z,]+)["'']?/i);
const scopes = scopeMatch ? scopeMatch[1] : undefined;
const tool = getCopilotTool("createApiKey");
if (!tool) return { message: "Error: createApiKey tool not found." };
const result = await tool.handler({
name,
machineId: "copilot",
scopes,
});
return {
message: result,
toolCalls: [{ name: "createApiKey", args: { name, scopes }, result }],
};
}
// Handle CLI executor — pass the full command
if (intent.tool === "runOmniRouteCli") {
const tool = getCopilotTool("runOmniRouteCli");
if (!tool) return { message: "Error: CLI executor not found." };
const result = await tool.handler(intent.args);
return {
message: result,
toolCalls: [{ name: "runOmniRouteCli", args: intent.args, result }],
};
}
// For all other tools, dispatch directly
const tool = getCopilotTool(intent.tool);
if (!tool)
return {
message: `I don't have a tool for that yet. Try asking in a different way.\n\n${getHelpResponse()}`,
};
const result = await tool.handler(intent.args);
return {
message: result,
toolCalls: [{ name: intent.tool, args: intent.args, result }],
};
}
+189
View File
@@ -0,0 +1,189 @@
/**
* OmniRoute Copilot — System Prompt / Knowledge Base
*
* Comprehensive documentation about OmniRoute's architecture, features,
* configuration, and internals. Serves as the Copilot's "wikipedia" to
* answer any user question about the app.
*
* This is the authoritative knowledge source. Update it when new features
* are added or architecture changes.
*/
export function getCopilotSystemPrompt(): string {
return `# OmniRoute Copilot — System Knowledge Base
Eres el asistente IA integrado de **OmniRoute**, un proxy/router unificado de AI.
Tu función es ayudar a los usuarios a configurar, entender y optimizar su instancia
de OmniRoute. Puedes controlar la app mediante herramientas, consultar el código
fuente mediante CodeGraph, y ejecutar comandos CLI.
---
## 1. WHAT IS OMNIROUTE?
OmniRoute is a unified AI proxy/router that provides a single OpenAI-compatible
endpoint to route requests across **212+ providers** (OpenAI, Anthropic, Gemini,
DeepSeek, Groq, xAI, Mistral, and many more). It supports:
- **Single endpoint**: One API key, one URL (/v1/chat/completions) for all providers
- **Smart routing**: Combos with 14 strategies (priority, weighted, round-robin, auto, etc.)
- **Resilience**: Circuit breakers, retry with exponential backoff, account fallback
- **MCP Server**: 37 tools across 3 transports (stdio, SSE, Streamable HTTP)
- **A2A Protocol**: Agent-to-Agent communication v0.3
- **Compression**: Prompt compression (lite, caveman, RTK, stacked)
- **MITM Proxy**: Intercept desktop AI apps and route through OmniRoute
- **Dashboard**: Web UI for monitoring and configuration
- **CLI**: Full command-line interface for headless operations
- **Webhooks**: HMAC-signed delivery with exponential backoff
- **Memory system**: Persistent conversational memory across sessions
- **Skills system**: Extensible skill framework with sandbox execution
---
## 2. ARCHITECTURE OVERVIEW
### Request Pipeline
\`\`\`
Client → API Route (/v1/chat/completions)
→ CORS → Body validation (Zod) → Auth check
→ API key policy enforcement
→ Guardrails (prompt injection guard)
→ Pre-request Middleware Hooks (NEW)
→ Task-aware routing / Combo resolution
→ Cache check (semantic/signature cache)
→ Rate limit check
→ Translate request (OpenAI → Provider format)
→ Executor (provider-specific)
→ buildUrl() → buildHeaders() → transformRequest()
→ fetch() with retry/exponential backoff
→ Translate response back
→ SSE stream or JSON response
\`\`\`
### Data Layer (SQLite)
- \`src/lib/db/\`: 45+ domain-specific modules
- \`core.ts\`: Singleton better-sqlite3 with WAL journaling
- \`migrationRunner.ts\`: Versioned SQL migrations (55+ files)
- \`localDb.ts\`: Re-export layer only — no logic
### Key Modules
- **open-sse/**: Core streaming engine (handlers, executors, translator)
- **src/app/api/**: Next.js App Router API routes
- **src/lib/**: Infrastructure (db, events, memory, skills, guardrails, etc.)
- **src/mitm/**: MITM proxy (cert management, DNS, targets)
- **src/server/**: Server infrastructure (WebSocket, authz)
- **bin/**: CLI entry points
---
## 3. KEY FEATURES
### 3.1 Providers (212+)
Registered in src/shared/constants/providers.ts. Categories:
- **Free** (3): Qoder AI, Qwen Code, Kiro AI
- **OAuth** (14): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, etc.
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, etc.
- **Self-Hosted** (8+): LM Studio, vLLM, Ollama, etc.
- **Custom**: openai-compatible-* and anthropic-compatible-* prefixes
### 3.2 Combos (Smart Routing)
Combos chain multiple provider targets with a routing strategy:
- **Priority**: Try targets in order, fall through on failure
- **Weighted**: Distribute load by weight
- **Round-robin**: Cycle through targets
- **Auto**: Intelligent selection
- **Fill-first / P2C / Random / Least-used**: Various distribution strategies
- **Cost-optimized / Context-optimized**: Optimize by cost or context
- **Context-relay / LKGP**: Advanced relay patterns
### 3.3 Circuit Breaker (NEW)
Intelligent circuit breaker with progressive degradation:
- States: CLOSED → DEGRADED → OPEN → HALF-OPEN
- Adaptive backoff by failure type (rate-limit vs auth vs timeout)
- Automatic probing in HALF-OPEN state
- Persisted in domain_circuit_breakers table
- Configurable per profile (OAuth vs API key)
### 3.4 Fail-Fast Credential Health Check (NEW)
Background scheduler that validates credentials every 5 minutes:
- Cache elimination: stale credentials skipped in <1ms
- Configurable via CREDENTIAL_HEALTH_CHECK_INTERVAL env var
- Disable via OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK
### 3.5 Pre-request Middleware Hooks (NEW)
Execute JS hooks before routing:
- 3 scopes: global, combo-specific, request-scoped
- Hook actions: mutate body/headers/model/combo, short-circuit
- Pipeline: Guardrails → HOOKS → Routing
### 3.6 API Key Groups (NEW)
Team/enterprise access control:
- Groups with model-level permissions
- Wildcard model patterns (claude-*, gpt-*)
- Deny-override for explicit blocking
### 3.7 Guardrails
3 built-in: pii-masker, prompt-injection, vision-bridge
Fail-open by default, per-request opt-out via header.
### 3.8 Compression
Modes: off, lite, standard, aggressive, ultra, rtk, stacked
Lite: collapse whitespace, dedup system, compress tool results, etc.
### 3.9 MCP Server (37 tools)
Core: health, combos, routing, cost, session, models, web search
Cache, compression, 1proxy, memory, skills tools
### 3.10 Webhooks
7 event types, exponential backoff, auto-disable at 10 failures.
---
## 4. ENVIRONMENT VARIABLES
| Variable | Description | Default |
|----------|-------------|---------|
| DATA_DIR | Data directory | ~/.omniroute/ |
| PORT | HTTP server port | 20128 |
| REQUIRE_API_KEY | Force API key auth | false |
| CREDENTIAL_HEALTH_CHECK_INTERVAL | Health check interval (ms) | 300000 |
| CREDENTIAL_HEALTH_CACHE_TTL | Credential cache TTL (ms) | 300000 |
| OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK | Disable health check | off |
---
## 5. TOOLS DISPONIBLES
Tienes acceso a estas herramientas para ayudar al usuario:
### Configuración
- **listProviders**: Lista proveedores configurados
- **listCombos**: Lista combos de routing
- **createCombo**: Crea un nuevo combo
- **listApiKeys**: Lista API keys
- **createApiKey**: Crea API key
- **revokeApiKey**: Revoca API key
- **listKeyGroups**: Lista grupos de keys
### CodeGraph (investigación del código)
- **searchCodeGraph**: Busca símbolos por nombre
- **findCallers**: Encuentra quién llama a un símbolo
- **findCallees**: Encuentra qué llama un símbolo
- **getFileContext**: Símbolos en un archivo
- **listCodeGraphFiles**: Archivos indexados
- **codeGraphStats**: Estadísticas del índice
### CLI (control total)
- **runOmniRouteCli**: Ejecuta comandos omniroute CLI
---
## 6. RESPONSE GUIDELINES
- Sé conciso y directo. Responde en español o inglés según el usuario.
- Cuando ejecutes herramientas, explica el resultado claramente.
- Si no estás seguro de algo, usa CodeGraph para investigar el código fuente.
- Para operaciones avanzadas, usa el CLI executor.
- Prioriza las herramientas específicas sobre el CLI executor cuando existan.
- Si el usuario pide crear algo (combo, API key), guíalo con preguntas específicas.`;
}
+415
View File
@@ -0,0 +1,415 @@
/**
* OmniRoute Copilot — Tool definitions
*
* Tools the copilot can execute to configure OmniRoute on behalf of the user,
* query the codebase via CodeGraph, and execute CLI commands for full control.
*/
import { execFile, execSync } from "node:child_process";
import { promisify } from "node:util";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const execFileAsync = promisify(execFile);
import { createCombo, getCombos, updateCombo } from "@/lib/db/combos";
import { getProviderConnections } from "@/lib/db/providers";
import { createApiKey, revokeApiKey, getApiKeys } from "@/lib/db/apiKeys";
import {
searchSymbols,
findCallers,
findCallees,
getFileContext,
listFiles,
getCodeGraphStats,
isCodeGraphAvailable,
type CodeGraphQueryResult,
} from "./codegraphKnowledge";
import { getAllKeyGroups } from "@/lib/db/apiKeyGroups";
// ── Tool Types ───────────────────────────────────────────────────────────────
export interface CopilotToolParam {
name: string;
type: "string" | "number" | "boolean";
description: string;
required: boolean;
}
export interface CopilotTool {
name: string;
description: string;
parameters: CopilotToolParam[];
handler: (args: Record<string, unknown>) => Promise<string>;
}
// ── Helper: format CodeGraph results ─────────────────────────────────────────
function formatCodeGraphResult(result: CodeGraphQueryResult): string {
if (!result.success) {
if (result.engine === "none") {
return `CodeGraph not available: ${result.error || "DB not found"}. The app runs without the development code index in production.`;
}
return `CodeGraph query error: ${result.error}`;
}
const rows = result.data as Record<string, unknown>[];
if (!rows || rows.length === 0) return "No results found.";
return (
JSON.stringify(rows.slice(0, 30), null, 2) +
(rows.length > 30 ? `\n... and ${rows.length - 30} more` : "")
);
}
// ── Helper: check if omniroute CLI is available ──────────────────────────────
function getOmniRouteCliPath(): string | null {
try {
const result = execSync("which omniroute 2>/dev/null || command -v omniroute 2>/dev/null", {
encoding: "utf-8",
timeout: 3000,
}).trim();
return result || null;
} catch {
return null;
}
}
// ── Tool Definitions ─────────────────────────────────────────────────────────
export const COPILOT_TOOLS: CopilotTool[] = [
// ── Provider Tools ──
{
name: "listProviders",
description:
"List all configured provider connections, optionally filtered by type (apikey, oauth, local, free)",
parameters: [
{
name: "type",
type: "string",
description: "Filter: apikey, oauth, local, free, or empty for all",
required: false,
},
],
handler: async (args) => {
const filter: Record<string, unknown> = {};
if (args.type) filter.type = args.type;
const connections = await getProviderConnections(filter);
const connectionsAny = connections as any[];
if (connectionsAny.length === 0) return "No provider connections found.";
let output = `**${connectionsAny.length} provider(s) configured**\n\n`;
for (const c of connectionsAny) {
const status = c.isActive ? "✅" : "⛔";
const models = c.models
? `(${(Array.isArray(c.models) ? c.models : JSON.parse(c.models || "[]")).length} models)`
: "";
output += `${status} **${c.displayName || c.name}** — \`${c.id}\` (${c.type}) ${models}\n`;
}
return output;
},
},
// ── Combo Tools ──
{
name: "listCombos",
description: "List all configured combos with their strategy and target count",
parameters: [],
handler: async () => {
const combos = await getCombos();
if (!combos || combos.length === 0)
return "No combos configured. Create one with createCombo.";
let output = `**${combos.length} combo(s) configured**\n\n`;
for (const c of combos as any[]) {
const active = c.isActive ? "✅" : "⛔";
const targets = c.targets
? typeof c.targets === "string"
? JSON.parse(c.targets).length
: c.targets.length
: 0;
output += `${active} **${c.name}** — strategy: \`${c.strategy}\`${targets} target(s)\n`;
}
return output;
},
},
{
name: "createCombo",
description: "Create a new routing combo with specified targets",
parameters: [
{ name: "name", type: "string", description: "Combo display name", required: true },
{
name: "strategy",
type: "string",
description: "Routing strategy: priority, weighted, round-robin, cost-optimized, auto",
required: true,
},
{
name: "targets",
type: "string",
description: "JSON array of targets: [{provider, model, weight?}]",
required: true,
},
],
handler: async (args) => {
const name = args.name as string;
const strategy = args.strategy as string;
if (!name || !strategy) return "Error: name and strategy are required.";
let targets: unknown[];
try {
targets = JSON.parse(args.targets as string);
} catch {
return "Error: targets must be valid JSON array.";
}
if (!Array.isArray(targets) || targets.length === 0) {
return "Error: targets must be a non-empty array.";
}
const combo = await createCombo({
name,
strategy,
targets: JSON.stringify(targets),
isActive: true,
});
const anyCombo = combo as any;
return `✅ Combo **${anyCombo.name || name}** created (ID: \`${anyCombo.id || "?"}\`) with ${targets.length} target(s).`;
},
},
// ── API Key Tools ──
{
name: "listApiKeys",
description: "List all API keys with their status and scope",
parameters: [],
handler: async () => {
const keys = await getApiKeys();
const keysAny = keys as any[];
if (!keysAny || keysAny.length === 0) return "No API keys configured.";
let output = `**${keysAny.length} API key(s)**\n\n`;
for (const k of keysAny) {
const status = k.isActive && !k.revokedAt ? "✅" : "⛔";
output += `${status} **${k.name}** — \`${k.keyPrefix || k.id}\`${k.scopes ? JSON.stringify(k.scopes) : "no scopes"}\n`;
}
return output;
},
},
{
name: "createApiKey",
description: "Create a new API key with optional scopes",
parameters: [
{ name: "name", type: "string", description: "Human-readable key name", required: true },
{ name: "machineId", type: "string", description: "Machine identifier", required: false },
{
name: "scopes",
type: "string",
description: "Comma-separated scopes (e.g., manage,read)",
required: false,
},
],
handler: async (args) => {
const name = args.name as string;
if (!name) return "Error: name is required.";
const scopes = args.scopes
? (args.scopes as string).split(",").map((s) => s.trim())
: undefined;
const result = await createApiKey(name, (args.machineId as string) || "copilot", scopes);
const r = result as any;
return `✅ API key **${name}** created:\n\`\`\`\n${r.key}\n\`\`\`\nSave this now — it won't be shown again.`;
},
},
{
name: "revokeApiKey",
description: "Revoke an API key by ID",
parameters: [
{ name: "id", type: "string", description: "API key ID to revoke", required: true },
],
handler: async (args) => {
const id = args.id as string;
if (!id) return "Error: id is required.";
await revokeApiKey(id);
return `✅ API key \`${id}\` revoked.`;
},
},
// ── Key Group Tools ──
{
name: "listKeyGroups",
description: "List all API key groups with their model permissions",
parameters: [],
handler: async () => {
const groups = await getAllKeyGroups();
const gArr = groups as any[];
if (!gArr || gArr.length === 0) return "No key groups configured.";
let output = `**${gArr.length} key group(s)**\n\n`;
for (const g of gArr) {
const perms = g.allowedModels
? (typeof g.allowedModels === "string"
? JSON.parse(g.allowedModels)
: g.allowedModels
).join(", ")
: "all models";
output += `📦 **${g.name}** — models: ${perms}\n`;
}
return output;
},
},
// ── CodeGraph Tools ──
{
name: "searchCodeGraph",
description:
"Search for symbols in the OmniRoute codebase by name (functions, classes, types, variables). Use this to understand how the app works internally.",
parameters: [
{
name: "query",
type: "string",
description:
"Symbol name or partial name to search (e.g., 'handleChat', 'sanitizeMessage', 'CircuitBreaker')",
required: true,
},
{ name: "limit", type: "number", description: "Max results (default 20)", required: false },
],
handler: async (args) => {
const q = args.query as string;
const limit = (args.limit as number) || 20;
if (!q) return "Please provide a search query.";
const result = searchSymbols(q, limit);
return formatCodeGraphResult(result);
},
},
{
name: "findCallers",
description:
"Find all code that calls or references a specific function/symbol. Useful for impact analysis — 'what would break if I changed X?'",
parameters: [
{
name: "symbol",
type: "string",
description: "Symbol name to find callers for (e.g., 'handleChatCore', 'translateRequest')",
required: true,
},
{ name: "limit", type: "number", description: "Max results (default 20)", required: false },
],
handler: async (args) => {
const symbol = args.symbol as string;
const limit = (args.limit as number) || 20;
if (!symbol) return "Please provide a symbol name.";
const result = findCallers(symbol, limit);
return formatCodeGraphResult(result);
},
},
{
name: "findCallees",
description:
"Find all functions/symbols that a specific function calls. Useful for understanding dependencies and code flow within OmniRoute.",
parameters: [
{
name: "symbol",
type: "string",
description: "Symbol name to find callees for (e.g., 'handleChatCore', 'getExecutor')",
required: true,
},
{ name: "limit", type: "number", description: "Max results (default 20)", required: false },
],
handler: async (args) => {
const symbol = args.symbol as string;
const limit = (args.limit as number) || 20;
if (!symbol) return "Please provide a symbol name.";
const result = findCallees(symbol, limit);
return formatCodeGraphResult(result);
},
},
{
name: "getFileContext",
description:
"Get all symbols defined in a specific file. Useful to understand a file's exports and structure at a glance.",
parameters: [
{
name: "filePath",
type: "string",
description: "File path (partial or full, e.g., 'chatCore.ts', 'combo.ts', 'src/lib/db/')",
required: true,
},
],
handler: async (args) => {
const fp = args.filePath as string;
if (!fp) return "Please provide a file path.";
const result = getFileContext(fp);
return formatCodeGraphResult(result);
},
},
{
name: "listCodeGraphFiles",
description:
"List all files indexed by CodeGraph, optionally filtered by language. Tells you what parts of the codebase are available for analysis.",
parameters: [
{
name: "language",
type: "string",
description: "Filter by language: typescript, javascript, python, etc.",
required: false,
},
],
handler: async (args) => {
const lang = args.language as string | undefined;
const result = listFiles(lang);
return formatCodeGraphResult(result);
},
},
{
name: "codeGraphStats",
description:
"Get summary stats about the CodeGraph index: total nodes, edges, files, languages, and node kinds indexed.",
parameters: [],
handler: async () => {
const result = getCodeGraphStats();
return formatCodeGraphResult(result);
},
},
// ── CLI Execution Tool ──
{
name: "runOmniRouteCli",
description:
"Execute an 'omniroute' CLI command to configure or query the OmniRoute app. Gives complete control over the app — use for advanced operations not covered by other tools. Common commands: omniroute list-keys, omniroute switch-combo [id], omniroute set-budget 10, omniroute set-strategy [id] priority, omniroute health, omniroute mcp (starts MCP server), omniroute db-health, omniroute reset-password.",
parameters: [
{
name: "command",
type: "string",
description:
"CLI command arguments (everything after 'omniroute'). Example: 'list-keys', 'switch-combo abc123', 'health'",
required: true,
},
],
handler: async (args) => {
const cmd = args.command as string;
if (!cmd) return "Please provide a command to execute.";
const cliPath = getOmniRouteCliPath();
if (!cliPath) return "omniroute CLI not found in PATH. Install OmniRoute first.";
try {
const trimmedCmd = cmd.trim();
if (!trimmedCmd) return "Please provide a command to execute.";
const argv = (trimmedCmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).map((arg) =>
arg.replace(/^["']|["']$/g, "")
);
const { stdout } = await execFileAsync(cliPath, argv, {
encoding: "utf-8",
timeout: 30000,
maxBuffer: 1024 * 1024,
});
return `\`\`\`\n${stdout.trim()}\n\`\`\``;
} catch (err: unknown) {
const e = err as { stderr?: string; stdout?: string; message?: string };
return `Error executing CLI command:\n${sanitizeErrorMessage(e.stderr || e.stdout || e.message || "Unknown error")}`;
}
},
},
];
// ── Tool Lookup ──────────────────────────────────────────────────────────────
export function getCopilotTool(name: string): CopilotTool | undefined {
return COPILOT_TOOLS.find((t) => t.name === name);
}
export function getCopilotToolDescriptions(): string {
return COPILOT_TOOLS.map((t) => `- **${t.name}**: ${t.description}`).join("\n");
}

Some files were not shown because too many files have changed in this diff Show More