chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
export async function requireCliToolsAuth(request: Request): Promise<Response | null> {
|
||||
return requireManagementAuth(request);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 `há ${diffMin} min`;
|
||||
if (diffHour < 24) return `há ${diffHour} h`;
|
||||
if (diffDay === 1) return "ontem";
|
||||
return `há ${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`;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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" : ""),
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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)" };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 : {};
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 }],
|
||||
};
|
||||
}
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user