feat(automation): model provider registry (Epic 1)
feat(automation): model provider registry (Epic 1)
This commit is contained in:
commit
2d309e8a75
51 changed files with 10364 additions and 5 deletions
24
.env.example
24
.env.example
|
|
@ -20,6 +20,12 @@
|
||||||
# Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres
|
# Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres
|
||||||
DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo"
|
DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo"
|
||||||
|
|
||||||
|
# UUID of the workspace that owns imported `plans/` rows (slug or UUID from Settings).
|
||||||
|
# For dogfooding this repo, use the `convoys-tasks` workspace id.
|
||||||
|
# Required by `pnpm --filter @tasks/database import:markdown-backlog` and
|
||||||
|
# `watch:markdown-backlog` (not loaded automatically — set in `.env`).
|
||||||
|
MARKDOWN_BACKLOG_WORKSPACE_ID="00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
# ----- Redis (CT 102 shared Redis) -----
|
# ----- Redis (CT 102 shared Redis) -----
|
||||||
# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11)
|
# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11)
|
||||||
# Local dev: redis://localhost:6379
|
# Local dev: redis://localhost:6379
|
||||||
|
|
@ -70,11 +76,25 @@ NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
|
||||||
DIRECTUS_FEEDBACK_TOKEN=""
|
DIRECTUS_FEEDBACK_TOKEN=""
|
||||||
|
|
||||||
# ----- AI Providers -----
|
# ----- AI Providers -----
|
||||||
# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through
|
# Registry aliases (Epic 1) read these env var NAMES from the DB — values
|
||||||
# local Ollama (CT 108) instead of the OpenAI API.
|
# live here on the server/worker host, never in Postgres.
|
||||||
|
#
|
||||||
|
# Local Ollama on Mac:
|
||||||
|
# OPENAI_BASE_URL=http://<mac-lan-ip>:11434/v1
|
||||||
|
# OPENAI_API_KEY=ollama
|
||||||
|
# OPENAI_MODEL=qwen2.5-coder:32b
|
||||||
|
#
|
||||||
|
# Local MLX (mlx-openai-server on Mac — preferred for parallel implementers):
|
||||||
|
# MLX_SERVER_URL=http://<mac-lan-ip>:8000/v1
|
||||||
|
# MLX_API_KEY=local
|
||||||
|
# (Register as openai_compatible alias qwen-coder-mlx in Settings when Epic 1 ships)
|
||||||
|
#
|
||||||
|
# Cloud / free-tier (see docs/adr-model-routing.md):
|
||||||
OPENAI_API_KEY=""
|
OPENAI_API_KEY=""
|
||||||
OPENAI_BASE_URL=""
|
OPENAI_BASE_URL=""
|
||||||
ANTHROPIC_API_KEY=""
|
ANTHROPIC_API_KEY=""
|
||||||
|
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||||
|
NVIDIA_API_KEY=""
|
||||||
|
|
||||||
# ----- MCP Server -----
|
# ----- MCP Server -----
|
||||||
MCP_SERVER_PORT=3001
|
MCP_SERVER_PORT=3001
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { modelTierSchema, providerTypeSchema } from "@tasks/shared/model-routing";
|
||||||
import { db } from "../db.js";
|
import { db } from "../db.js";
|
||||||
import { eq } from "../drizzle.js";
|
import { eq } from "../drizzle.js";
|
||||||
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
|
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
|
||||||
|
|
@ -44,6 +45,15 @@ const completeTaskInputSchema = z.object({
|
||||||
.describe(
|
.describe(
|
||||||
"Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.",
|
"Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.",
|
||||||
),
|
),
|
||||||
|
modelAlias: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(120)
|
||||||
|
.optional()
|
||||||
|
.describe("Registry alias used for this session (e.g. qwen-coder)."),
|
||||||
|
modelTier: modelTierSchema.optional(),
|
||||||
|
providerType: providerTypeSchema.optional(),
|
||||||
|
modelId: z.string().min(1).max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
|
type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
|
||||||
|
|
@ -115,6 +125,10 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
||||||
const incomingMeta: Record<string, unknown> = {};
|
const incomingMeta: Record<string, unknown> = {};
|
||||||
if (input.client) incomingMeta.client = input.client;
|
if (input.client) incomingMeta.client = input.client;
|
||||||
if (input.model) incomingMeta.model = input.model;
|
if (input.model) incomingMeta.model = input.model;
|
||||||
|
if (input.modelAlias) incomingMeta.modelAlias = input.modelAlias;
|
||||||
|
if (input.modelTier) incomingMeta.modelTier = input.modelTier;
|
||||||
|
if (input.providerType) incomingMeta.providerType = input.providerType;
|
||||||
|
if (input.modelId) incomingMeta.modelId = input.modelId;
|
||||||
const hasIncomingMeta = Object.keys(incomingMeta).length > 0;
|
const hasIncomingMeta = Object.keys(incomingMeta).length > 0;
|
||||||
const mergedMeta = hasIncomingMeta
|
const mergedMeta = hasIncomingMeta
|
||||||
? { ...(run.metadata ?? {}), ...incomingMeta }
|
? { ...(run.metadata ?? {}), ...incomingMeta }
|
||||||
|
|
@ -131,6 +145,14 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
||||||
tokensTotal,
|
tokensTotal,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
error: input.error ?? null,
|
error: input.error ?? null,
|
||||||
|
...(input.modelAlias !== undefined
|
||||||
|
? { modelAlias: input.modelAlias }
|
||||||
|
: {}),
|
||||||
|
...(input.modelTier !== undefined ? { modelTier: input.modelTier } : {}),
|
||||||
|
...(input.providerType !== undefined
|
||||||
|
? { providerType: input.providerType }
|
||||||
|
: {}),
|
||||||
|
...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
|
||||||
...(hasIncomingMeta ? { metadata: mergedMeta } : {}),
|
...(hasIncomingMeta ? { metadata: mergedMeta } : {}),
|
||||||
})
|
})
|
||||||
.where(eq(agentRuns.id, input.runId));
|
.where(eq(agentRuns.id, input.runId));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { Bot, Loader2, Plus, Sparkles, X } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import type { ProviderType } from "@tasks/shared/model-routing";
|
||||||
|
|
||||||
|
const PROVIDER_TYPES: ProviderType[] = [
|
||||||
|
"anthropic",
|
||||||
|
"openai",
|
||||||
|
"google",
|
||||||
|
"openai_compatible",
|
||||||
|
"azure_openai",
|
||||||
|
];
|
||||||
|
|
||||||
|
type ProviderForm = {
|
||||||
|
alias: string;
|
||||||
|
displayName: string;
|
||||||
|
providerType: ProviderType;
|
||||||
|
modelId: string;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKeyEnvVar: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: ProviderForm = {
|
||||||
|
alias: "",
|
||||||
|
displayName: "",
|
||||||
|
providerType: "openai_compatible",
|
||||||
|
modelId: "",
|
||||||
|
baseUrl: "",
|
||||||
|
apiKeyEnvVar: "",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRESETS: Array<{ label: string; form: Partial<ProviderForm> }> = [
|
||||||
|
{
|
||||||
|
label: "Ollama local",
|
||||||
|
form: {
|
||||||
|
alias: "qwen-coder",
|
||||||
|
displayName: "Qwen Coder (Ollama)",
|
||||||
|
providerType: "openai_compatible",
|
||||||
|
modelId: "qwen2.5-coder:32b",
|
||||||
|
baseUrl: "http://127.0.0.1:11434/v1",
|
||||||
|
apiKeyEnvVar: "OLLAMA_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "MLX local",
|
||||||
|
form: {
|
||||||
|
alias: "qwen-coder-mlx",
|
||||||
|
displayName: "Qwen Coder (MLX)",
|
||||||
|
providerType: "openai_compatible",
|
||||||
|
modelId: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit",
|
||||||
|
baseUrl: "http://127.0.0.1:8000/v1",
|
||||||
|
apiKeyEnvVar: "MLX_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Anthropic",
|
||||||
|
form: {
|
||||||
|
alias: "claude-planning",
|
||||||
|
displayName: "Claude Planning",
|
||||||
|
providerType: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-20250514",
|
||||||
|
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Gemini",
|
||||||
|
form: {
|
||||||
|
alias: "gemini-flash",
|
||||||
|
displayName: "Gemini Flash",
|
||||||
|
providerType: "google",
|
||||||
|
modelId: "gemini-2.0-flash",
|
||||||
|
apiKeyEnvVar: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "NVIDIA NIM",
|
||||||
|
form: {
|
||||||
|
alias: "nim-llama-70b",
|
||||||
|
displayName: "NIM Llama 70B",
|
||||||
|
providerType: "openai_compatible",
|
||||||
|
modelId: "meta/llama-3.1-70b-instruct",
|
||||||
|
baseUrl: "https://integrate.api.nvidia.com/v1",
|
||||||
|
apiKeyEnvVar: "NVIDIA_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AiProvidersSettingsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const listQuery = api.ai.models.listProviders.useQuery(
|
||||||
|
{ workspace: workspaceSlug ?? "" },
|
||||||
|
{ enabled: Boolean(workspaceSlug) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
const [form, setForm] = React.useState<ProviderForm>(EMPTY_FORM);
|
||||||
|
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const createMut = api.ai.models.createProvider.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.ai.models.listProviders.invalidate();
|
||||||
|
setDialogOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updateMut = api.ai.models.updateProvider.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.ai.models.listProviders.invalidate();
|
||||||
|
setDialogOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteMut = api.ai.models.deleteProvider.useMutation({
|
||||||
|
onSuccess: () => void utils.ai.models.listProviders.invalidate(),
|
||||||
|
});
|
||||||
|
const testMut = api.ai.models.testProvider.useMutation({
|
||||||
|
onSuccess: (result, vars) => {
|
||||||
|
setTestResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[vars.providerId]: result.ok
|
||||||
|
? `OK in ${result.latencyMs}ms`
|
||||||
|
: `Failed: ${result.error ?? "unknown"}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canManage = listQuery.data?.canManage ?? false;
|
||||||
|
const providers = listQuery.data?.providers ?? [];
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (p: (typeof providers)[number]) => {
|
||||||
|
setEditingId(p.id);
|
||||||
|
setForm({
|
||||||
|
alias: p.alias,
|
||||||
|
displayName: p.displayName,
|
||||||
|
providerType: p.providerType,
|
||||||
|
modelId: p.modelId,
|
||||||
|
baseUrl: p.baseUrl ?? "",
|
||||||
|
apiKeyEnvVar: p.apiKeyEnvVar ?? "",
|
||||||
|
enabled: p.enabled,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
const payload = {
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
alias: form.alias,
|
||||||
|
displayName: form.displayName,
|
||||||
|
providerType: form.providerType,
|
||||||
|
modelId: form.modelId,
|
||||||
|
baseUrl: form.baseUrl.trim() || null,
|
||||||
|
apiKeyEnvVar: form.apiKeyEnvVar.trim() || null,
|
||||||
|
enabled: form.enabled,
|
||||||
|
};
|
||||||
|
if (editingId) {
|
||||||
|
updateMut.mutate({ ...payload, id: editingId });
|
||||||
|
} else {
|
||||||
|
createMut.mutate(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!workspaceSlug) {
|
||||||
|
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Sparkles className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">AI model providers</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Registry aliases for orchestrator and pipeline roles. API keys live in server env
|
||||||
|
vars only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Button size="sm" className="gap-1.5" onClick={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add provider
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsAiNav workspaceSlug={workspaceSlug} />
|
||||||
|
|
||||||
|
{listQuery.isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : listQuery.error ? (
|
||||||
|
<p className="text-sm text-destructive">{listQuery.error.message}</p>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
|
||||||
|
<Bot className="mx-auto mb-3 size-8 opacity-40" />
|
||||||
|
No providers yet.{" "}
|
||||||
|
{canManage ? "Add Ollama, MLX, or cloud aliases to get started." : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Alias</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Model</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Base URL</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">On</th>
|
||||||
|
{canManage ? (
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{providers.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="font-medium">{p.alias}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{p.displayName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{p.providerType}</td>
|
||||||
|
<td className="max-w-[20ch] truncate px-4 py-2 font-mono text-xs">
|
||||||
|
{p.modelId}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||||
|
{p.baseUrl ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs">{p.enabled ? "yes" : "no"}</td>
|
||||||
|
{canManage ? (
|
||||||
|
<td className="space-x-2 px-4 py-2 text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={testMut.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
testMut.mutate({ workspace: workspaceSlug, providerId: p.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete provider "${p.alias}"?`)) {
|
||||||
|
deleteMut.mutate({ workspace: workspaceSlug, id: p.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
{testResult[p.id] ? (
|
||||||
|
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
{testResult[p.id]}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||||
|
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<DialogPrimitive.Title className="text-lg font-semibold">
|
||||||
|
{editingId ? "Edit provider" : "Add provider"}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<button type="button" className="rounded-md p-1 hover:bg-muted">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => setForm({ ...EMPTY_FORM, ...preset.form })}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Alias">
|
||||||
|
<Input
|
||||||
|
value={form.alias}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Display name">
|
||||||
|
<Input
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Provider type">
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={form.providerType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
providerType: e.target.value as ProviderType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{PROVIDER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Model ID">
|
||||||
|
<Input
|
||||||
|
value={form.modelId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, modelId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{form.providerType === "openai_compatible" ||
|
||||||
|
form.providerType === "azure_openai" ? (
|
||||||
|
<Field label="Base URL">
|
||||||
|
<Input
|
||||||
|
value={form.baseUrl}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field label="API key env var">
|
||||||
|
<Input
|
||||||
|
value={form.apiKeyEnvVar}
|
||||||
|
placeholder="ANTHROPIC_API_KEY"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, apiKeyEnvVar: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
Secret value is read from the server environment at inference time — never stored
|
||||||
|
in the database.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={createMut.isPending || updateMut.isPending}
|
||||||
|
>
|
||||||
|
{createMut.isPending || updateMut.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { GitBranch, Loader2, Plus, Route } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
"role-conductor": "Conductor",
|
||||||
|
"role-architect": "Architect",
|
||||||
|
"role-ia-architect": "IA architect",
|
||||||
|
"role-ux-reviewer": "UX reviewer",
|
||||||
|
"role-implementer": "Implementer",
|
||||||
|
"role-reviewer": "Reviewer",
|
||||||
|
"role-design-system-auditor": "Design system auditor",
|
||||||
|
"role-a11y-auditor": "A11y auditor",
|
||||||
|
"role-doc-writer": "Doc writer",
|
||||||
|
"gate-copilot": "Gate co-pilot",
|
||||||
|
"chat-default": "Chat default",
|
||||||
|
};
|
||||||
|
|
||||||
|
function roleLabel(roleId: string): string {
|
||||||
|
return ROLE_LABELS[roleId] ?? roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTier(metadata: unknown): string | null {
|
||||||
|
if (!metadata || typeof metadata !== "object") return null;
|
||||||
|
const tier = (metadata as { tier?: unknown }).tier;
|
||||||
|
return typeof tier === "string" ? tier : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiRoleRoutingSettingsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const routesQuery = api.ai.models.listRoleRoutes.useQuery(
|
||||||
|
{ workspace: workspaceSlug ?? "" },
|
||||||
|
{ enabled: Boolean(workspaceSlug) },
|
||||||
|
);
|
||||||
|
const providersQuery = api.ai.models.listProviders.useQuery(
|
||||||
|
{ workspace: workspaceSlug ?? "" },
|
||||||
|
{ enabled: Boolean(workspaceSlug) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newRoleId, setNewRoleId] = React.useState("");
|
||||||
|
const [newProviderId, setNewProviderId] = React.useState("");
|
||||||
|
|
||||||
|
const upsertMut = api.ai.models.upsertRoleRoute.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.ai.models.listRoleRoutes.invalidate();
|
||||||
|
setNewRoleId("");
|
||||||
|
setNewProviderId("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteMut = api.ai.models.deleteRoleRoute.useMutation({
|
||||||
|
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
|
||||||
|
});
|
||||||
|
const resetMut = api.ai.models.resetPipelineDefaults.useMutation({
|
||||||
|
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const canManage = routesQuery.data?.canManage ?? false;
|
||||||
|
const routes = routesQuery.data?.routes ?? [];
|
||||||
|
const enabledProviders =
|
||||||
|
providersQuery.data?.providers.filter((p) => p.enabled) ?? [];
|
||||||
|
|
||||||
|
if (!workspaceSlug) {
|
||||||
|
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Route className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Role model routing</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Map pipeline <code className="text-[10px]">role_id</code> strings to provider aliases.
|
||||||
|
Convoy <code className="text-[10px]">model_policy</code> overrides these at dispatch
|
||||||
|
time — see docs/adr-model-routing.md.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={resetMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"Insert pipeline default routes for roles whose aliases already exist?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
resetMut.mutate({ workspace: workspaceSlug });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset pipeline defaults
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsAiNav workspaceSlug={workspaceSlug} />
|
||||||
|
|
||||||
|
{routesQuery.isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Role</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Provider alias</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Tier</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">On</th>
|
||||||
|
{canManage ? (
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{routes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={canManage ? 5 : 4}
|
||||||
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No routes configured. Add providers first, then map roles.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
routes.map((route) => {
|
||||||
|
const tier = readTier(route.providerMetadata);
|
||||||
|
return (
|
||||||
|
<tr key={route.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="font-medium">{roleLabel(route.roleId)}</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{route.roleId}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{route.providerAlias}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{tier ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{tier}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs">
|
||||||
|
{route.enabled && route.providerEnabled ? "yes" : "no"}
|
||||||
|
</td>
|
||||||
|
{canManage ? (
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<select
|
||||||
|
className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||||
|
value={route.providerAliasId}
|
||||||
|
onChange={(e) =>
|
||||||
|
upsertMut.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
roleId: route.roleId,
|
||||||
|
providerAliasId: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{enabledProviders.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.alias}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Remove route for ${route.roleId}?`)) {
|
||||||
|
deleteMut.mutate({ workspace: workspaceSlug, id: route.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<div className="mt-8 rounded-lg border bg-muted/20 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<GitBranch className="size-4" />
|
||||||
|
Add custom role
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">role_id</label>
|
||||||
|
<Input
|
||||||
|
className="w-48 font-mono text-xs"
|
||||||
|
placeholder="gate-copilot"
|
||||||
|
value={newRoleId}
|
||||||
|
onChange={(e) => setNewRoleId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">Provider</label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={newProviderId}
|
||||||
|
onChange={(e) => setNewProviderId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{enabledProviders.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.alias}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={!newRoleId.trim() || !newProviderId || upsertMut.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
upsertMut.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
roleId: newRoleId.trim(),
|
||||||
|
providerAliasId: newProviderId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ function formatTimestamp(when: Date | string): string {
|
||||||
* for in-flight runs (no `finishedAt`). Below a minute we show seconds
|
* for in-flight runs (no `finishedAt`). Below a minute we show seconds
|
||||||
* with a single decimal so quick MCP calls don't all look like "0s".
|
* with a single decimal so quick MCP calls don't all look like "0s".
|
||||||
*/
|
*/
|
||||||
export function formatDuration(
|
function formatDuration(
|
||||||
startedAt: Date | string,
|
startedAt: Date | string,
|
||||||
finishedAt: Date | string | null,
|
finishedAt: Date | string | null,
|
||||||
): string {
|
): string {
|
||||||
|
|
@ -105,6 +105,19 @@ function describeOutcome(
|
||||||
* null and ignore values that aren't strings (defensive against
|
* null and ignore values that aren't strings (defensive against
|
||||||
* hand-inserted rows from psql).
|
* hand-inserted rows from psql).
|
||||||
*/
|
*/
|
||||||
|
function describeModel(row: {
|
||||||
|
modelAlias: string | null;
|
||||||
|
modelTier: string | null;
|
||||||
|
modelId: string | null;
|
||||||
|
metadata: unknown;
|
||||||
|
}): string | null {
|
||||||
|
if (row.modelAlias) {
|
||||||
|
const tier = row.modelTier ? ` (${row.modelTier})` : "";
|
||||||
|
return `${row.modelAlias}${tier}`;
|
||||||
|
}
|
||||||
|
return describeAgent(row.metadata).label;
|
||||||
|
}
|
||||||
|
|
||||||
function describeAgent(metadata: unknown): {
|
function describeAgent(metadata: unknown): {
|
||||||
client: string | null;
|
client: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
|
|
@ -221,6 +234,7 @@ export default function RunsPage() {
|
||||||
<th className="px-4 py-2 text-left font-medium">Started</th>
|
<th className="px-4 py-2 text-left font-medium">Started</th>
|
||||||
<th className="px-4 py-2 text-left font-medium">Duration</th>
|
<th className="px-4 py-2 text-left font-medium">Duration</th>
|
||||||
<th className="px-4 py-2 text-left font-medium">Actor</th>
|
<th className="px-4 py-2 text-left font-medium">Actor</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Model</th>
|
||||||
<th className="px-4 py-2 text-left font-medium">Outcome</th>
|
<th className="px-4 py-2 text-left font-medium">Outcome</th>
|
||||||
<th className="px-4 py-2 text-right font-medium">Tokens</th>
|
<th className="px-4 py-2 text-right font-medium">Tokens</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -229,6 +243,7 @@ export default function RunsPage() {
|
||||||
{runsQuery.data.rows.map((row) => {
|
{runsQuery.data.rows.map((row) => {
|
||||||
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
|
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
|
||||||
const agent = describeAgent(row.metadata);
|
const agent = describeAgent(row.metadata);
|
||||||
|
const modelLabel = describeModel(row);
|
||||||
const isOpen = expanded === row.id;
|
const isOpen = expanded === row.id;
|
||||||
const hasDetail = Boolean(row.notes || row.error);
|
const hasDetail = Boolean(row.notes || row.error);
|
||||||
return (
|
return (
|
||||||
|
|
@ -289,6 +304,9 @@ export default function RunsPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="max-w-[16ch] truncate px-4 py-2 text-xs text-muted-foreground">
|
||||||
|
{modelLabel ?? "—"}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -306,7 +324,7 @@ export default function RunsPage() {
|
||||||
{isOpen && hasDetail ? (
|
{isOpen && hasDetail ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td />
|
<td />
|
||||||
<td colSpan={6} className="bg-muted/20 px-4 py-3 text-xs">
|
<td colSpan={7} className="bg-muted/20 px-4 py-3 text-xs">
|
||||||
{row.notes ? (
|
{row.notes ? (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
|
Sparkles,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Zap,
|
Zap,
|
||||||
|
|
@ -144,6 +145,24 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
|
||||||
<ScrollText className="size-4 text-muted-foreground" />
|
<ScrollText className="size-4 text-muted-foreground" />
|
||||||
Audit log
|
Audit log
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2.5 px-4"
|
||||||
|
onSelect={() =>
|
||||||
|
workspaceId && router.push(`/${workspaceId}/settings/ai/providers`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4 text-muted-foreground" />
|
||||||
|
AI providers
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2.5 px-4"
|
||||||
|
onSelect={() =>
|
||||||
|
workspaceId && router.push(`/${workspaceId}/settings/ai/role-routing`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4 text-muted-foreground" />
|
||||||
|
Role routing
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-2.5 px-4"
|
className="gap-2.5 px-4"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
|
|
|
||||||
39
apps/web/components/settings/settings-ai-nav.tsx
Normal file
39
apps/web/components/settings/settings-ai-nav.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{ segment: "providers", label: "Providers" },
|
||||||
|
{ segment: "role-routing", label: "Role routing" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SettingsAiNav({ workspaceSlug }: { workspaceSlug: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const base = `/${workspaceSlug}/settings/ai`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="mb-8 flex gap-1 border-b border-border">
|
||||||
|
{LINKS.map((link) => {
|
||||||
|
const href = `${base}/${link.segment}`;
|
||||||
|
const active = pathname === href || pathname?.startsWith(`${href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.segment}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
533
apps/web/server/routers/ai-models.ts
Normal file
533
apps/web/server/routers/ai-models.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||||
|
import { seedDefaultRoleRoutes } from "@tasks/database/model-routing";
|
||||||
|
import {
|
||||||
|
modelProviders,
|
||||||
|
roleModelRoutes,
|
||||||
|
} from "@tasks/database/schema";
|
||||||
|
import {
|
||||||
|
convoyModelPolicySchema,
|
||||||
|
providerTypeSchema,
|
||||||
|
resolveModel,
|
||||||
|
type ModelProvider,
|
||||||
|
type RoleModelRoute,
|
||||||
|
} from "@tasks/shared/model-routing";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { generateText } from "ai";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { rateLimit } from "@/server/lib/rate-limit";
|
||||||
|
import { router, workspaceProcedure } from "@/server/trpc";
|
||||||
|
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
||||||
|
|
||||||
|
type ProviderRow = typeof modelProviders.$inferSelect;
|
||||||
|
type RouteRow = typeof roleModelRoutes.$inferSelect;
|
||||||
|
|
||||||
|
function assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void {
|
||||||
|
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only workspace owners and admins can manage model providers.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean {
|
||||||
|
return ctx.workspace.role === "owner" || ctx.workspace.role === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProviderRow(row: ProviderRow): ModelProvider {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
alias: row.alias,
|
||||||
|
displayName: row.displayName,
|
||||||
|
providerType: providerTypeSchema.parse(row.providerType),
|
||||||
|
modelId: row.modelId,
|
||||||
|
baseUrl: row.baseUrl,
|
||||||
|
apiKeyEnvVar: row.apiKeyEnvVar,
|
||||||
|
extraHeaders: row.extraHeaders,
|
||||||
|
defaultParams: row.defaultParams,
|
||||||
|
metadata: row.metadata,
|
||||||
|
enabled: row.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRouteRow(row: RouteRow): RoleModelRoute {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
roleId: row.roleId,
|
||||||
|
providerAliasId: row.providerAliasId,
|
||||||
|
enabled: row.enabled,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readApiKey(envVarName: string): string | undefined {
|
||||||
|
return process.env[envVarName]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProviderFields(input: {
|
||||||
|
providerType: z.infer<typeof providerTypeSchema>;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
}): void {
|
||||||
|
if (input.providerType === "openai_compatible" && !input.baseUrl?.trim()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "baseUrl is required for openai_compatible providers.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerToResolved(row: ProviderRow): ReturnType<typeof resolveModel> {
|
||||||
|
const provider = mapProviderRow(row);
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "test",
|
||||||
|
providers: [provider],
|
||||||
|
routes: [],
|
||||||
|
briefRecommendedModel: provider.alias,
|
||||||
|
getApiKey: readApiKey,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerFieldsSchema = z.object({
|
||||||
|
alias: z.string().min(1).max(120),
|
||||||
|
displayName: z.string().min(1).max(200),
|
||||||
|
providerType: providerTypeSchema,
|
||||||
|
modelId: z.string().min(1).max(200),
|
||||||
|
baseUrl: z.string().url().nullable().optional(),
|
||||||
|
apiKeyEnvVar: z.string().min(1).max(120).nullable().optional(),
|
||||||
|
extraHeaders: z.record(z.string()).nullable().optional(),
|
||||||
|
defaultParams: z
|
||||||
|
.object({
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
maxTokens: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
metadata: z.record(z.unknown()).nullable().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiModelsRouter = router({
|
||||||
|
listProviders: workspaceProcedure.query(async ({ ctx }) => {
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(modelProviders)
|
||||||
|
.where(eq(modelProviders.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(asc(modelProviders.alias));
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers: rows.map(mapProviderRow),
|
||||||
|
canManage: canManageWorkspace(ctx),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
createProvider: workspaceProcedure
|
||||||
|
.input(providerFieldsSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
validateProviderFields(input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [created] = await ctx.db
|
||||||
|
.insert(modelProviders)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
alias: input.alias.trim(),
|
||||||
|
displayName: input.displayName.trim(),
|
||||||
|
providerType: input.providerType,
|
||||||
|
modelId: input.modelId.trim(),
|
||||||
|
baseUrl: input.baseUrl?.trim() || null,
|
||||||
|
apiKeyEnvVar: input.apiKeyEnvVar?.trim() || null,
|
||||||
|
extraHeaders: input.extraHeaders ?? null,
|
||||||
|
defaultParams: input.defaultParams ?? null,
|
||||||
|
metadata: input.metadata ?? null,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create provider.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapProviderRow(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (isUniqueViolation(e)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: `Alias "${input.alias}" already exists in this workspace.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateProvider: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
providerFieldsSchema.partial().extend({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
|
||||||
|
const [existing] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(modelProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(modelProviders.id, input.id),
|
||||||
|
eq(modelProviders.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextType = input.providerType ?? existing.providerType;
|
||||||
|
const nextBaseUrl =
|
||||||
|
input.baseUrl !== undefined ? input.baseUrl : existing.baseUrl;
|
||||||
|
validateProviderFields({
|
||||||
|
providerType: providerTypeSchema.parse(nextType),
|
||||||
|
baseUrl: nextBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [updated] = await ctx.db
|
||||||
|
.update(modelProviders)
|
||||||
|
.set({
|
||||||
|
...(input.alias !== undefined ? { alias: input.alias.trim() } : {}),
|
||||||
|
...(input.displayName !== undefined
|
||||||
|
? { displayName: input.displayName.trim() }
|
||||||
|
: {}),
|
||||||
|
...(input.providerType !== undefined
|
||||||
|
? { providerType: input.providerType }
|
||||||
|
: {}),
|
||||||
|
...(input.modelId !== undefined ? { modelId: input.modelId.trim() } : {}),
|
||||||
|
...(input.baseUrl !== undefined
|
||||||
|
? { baseUrl: input.baseUrl?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(input.apiKeyEnvVar !== undefined
|
||||||
|
? { apiKeyEnvVar: input.apiKeyEnvVar?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(input.extraHeaders !== undefined
|
||||||
|
? { extraHeaders: input.extraHeaders }
|
||||||
|
: {}),
|
||||||
|
...(input.defaultParams !== undefined
|
||||||
|
? { defaultParams: input.defaultParams }
|
||||||
|
: {}),
|
||||||
|
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
|
||||||
|
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(modelProviders.id, input.id),
|
||||||
|
eq(modelProviders.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapProviderRow(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (isUniqueViolation(e)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Another provider already uses that alias.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteProvider: workspaceProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const [deleted] = await ctx.db
|
||||||
|
.delete(modelProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(modelProviders.id, input.id),
|
||||||
|
eq(modelProviders.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: modelProviders.id });
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: deleted.id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
testProvider: workspaceProcedure
|
||||||
|
.input(z.object({ providerId: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
|
||||||
|
const limit = rateLimit({
|
||||||
|
key: `ai:testProvider:${ctx.workspace.id}`,
|
||||||
|
limit: 10,
|
||||||
|
windowMs: 60_000,
|
||||||
|
});
|
||||||
|
if (!limit.allowed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Test rate limit exceeded. Try again shortly.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(modelProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(modelProviders.id, input.providerId),
|
||||||
|
eq(modelProviders.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = providerToResolved(row);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: resolved.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createLanguageModelFromResolved(resolved.model);
|
||||||
|
if (!client.ok) {
|
||||||
|
return { ok: false as const, latencyMs: 0, error: client.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
await generateText({
|
||||||
|
model: client.model,
|
||||||
|
prompt: "Reply with exactly: OK",
|
||||||
|
maxTokens: 5,
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
listRoleRoutes: workspaceProcedure.query(async ({ ctx }) => {
|
||||||
|
const routes = await ctx.db
|
||||||
|
.select({
|
||||||
|
route: roleModelRoutes,
|
||||||
|
providerAlias: modelProviders.alias,
|
||||||
|
providerEnabled: modelProviders.enabled,
|
||||||
|
providerMetadata: modelProviders.metadata,
|
||||||
|
})
|
||||||
|
.from(roleModelRoutes)
|
||||||
|
.innerJoin(
|
||||||
|
modelProviders,
|
||||||
|
eq(roleModelRoutes.providerAliasId, modelProviders.id),
|
||||||
|
)
|
||||||
|
.where(eq(roleModelRoutes.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(asc(roleModelRoutes.sortOrder), asc(roleModelRoutes.roleId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
routes: routes.map((row) => ({
|
||||||
|
...mapRouteRow(row.route),
|
||||||
|
providerAlias: row.providerAlias,
|
||||||
|
providerEnabled: row.providerEnabled,
|
||||||
|
providerMetadata: row.providerMetadata,
|
||||||
|
})),
|
||||||
|
canManage: canManageWorkspace(ctx),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
upsertRoleRoute: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roleId: z.string().min(1).max(120),
|
||||||
|
providerAliasId: z.string().uuid(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
|
||||||
|
const [provider] = await ctx.db
|
||||||
|
.select({ id: modelProviders.id })
|
||||||
|
.from(modelProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(modelProviders.id, input.providerAliasId),
|
||||||
|
eq(modelProviders.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await ctx.db
|
||||||
|
.select({ id: roleModelRoutes.id })
|
||||||
|
.from(roleModelRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleModelRoutes.workspaceId, ctx.workspace.id),
|
||||||
|
eq(roleModelRoutes.roleId, input.roleId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await ctx.db
|
||||||
|
.update(roleModelRoutes)
|
||||||
|
.set({
|
||||||
|
providerAliasId: input.providerAliasId,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleModelRoutes.id, existing.id),
|
||||||
|
eq(roleModelRoutes.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapRouteRow(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await ctx.db
|
||||||
|
.insert(roleModelRoutes)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
roleId: input.roleId,
|
||||||
|
providerAliasId: input.providerAliasId,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
sortOrder: input.sortOrder ?? 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create role route.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapRouteRow(created);
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteRoleRoute: workspaceProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const [deleted] = await ctx.db
|
||||||
|
.delete(roleModelRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleModelRoutes.id, input.id),
|
||||||
|
eq(roleModelRoutes.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: roleModelRoutes.id });
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: deleted.id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetPipelineDefaults: workspaceProcedure.mutation(async ({ ctx }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const result = await seedDefaultRoleRoutes(ctx.db, ctx.workspace.id);
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
|
||||||
|
resolveForRole: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roleId: z.string().min(1).max(120),
|
||||||
|
briefRecommendedModel: z.string().optional(),
|
||||||
|
convoyPolicy: convoyModelPolicySchema.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const providerRows = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(modelProviders)
|
||||||
|
.where(eq(modelProviders.workspaceId, ctx.workspace.id));
|
||||||
|
|
||||||
|
const routeRows = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(roleModelRoutes)
|
||||||
|
.where(eq(roleModelRoutes.workspaceId, ctx.workspace.id));
|
||||||
|
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: input.roleId,
|
||||||
|
providers: providerRows.map(mapProviderRow),
|
||||||
|
routes: routeRows.map(mapRouteRow),
|
||||||
|
briefRecommendedModel: input.briefRecommendedModel,
|
||||||
|
convoyPolicy: input.convoyPolicy,
|
||||||
|
getApiKey: readApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey: _removed, ...publicModel } = result.model;
|
||||||
|
void _removed;
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
model: publicModel,
|
||||||
|
source: result.source,
|
||||||
|
resolvedAlias: result.resolvedAlias,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"code" in error &&
|
||||||
|
(error as { code?: string }).code === "23505"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { z } from "zod";
|
||||||
import { db as dbInstance } from "@tasks/database";
|
import { db as dbInstance } from "@tasks/database";
|
||||||
import { objects } from "@tasks/database/schema";
|
import { objects } from "@tasks/database/schema";
|
||||||
import { router, workspaceProcedure } from "@/server/trpc";
|
import { router, workspaceProcedure } from "@/server/trpc";
|
||||||
|
import { aiModelsRouter } from "@/server/routers/ai-models";
|
||||||
|
|
||||||
type Db = typeof dbInstance;
|
type Db = typeof dbInstance;
|
||||||
|
|
||||||
|
|
@ -185,4 +186,6 @@ export const aiRouter = router({
|
||||||
}
|
}
|
||||||
return { actions: suggestionsForContext({ ...input, objectType }) };
|
return { actions: suggestionsForContext({ ...input, objectType }) };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
models: aiModelsRouter,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ export const runsRouter = router({
|
||||||
tokensTotal: agentRuns.tokensTotal,
|
tokensTotal: agentRuns.tokensTotal,
|
||||||
notes: agentRuns.notes,
|
notes: agentRuns.notes,
|
||||||
metadata: agentRuns.metadata,
|
metadata: agentRuns.metadata,
|
||||||
|
modelAlias: agentRuns.modelAlias,
|
||||||
|
providerType: agentRuns.providerType,
|
||||||
|
modelId: agentRuns.modelId,
|
||||||
|
modelTier: agentRuns.modelTier,
|
||||||
})
|
})
|
||||||
.from(agentRuns)
|
.from(agentRuns)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
|
|
@ -147,6 +151,10 @@ export const runsRouter = router({
|
||||||
tokensTotal: agentRuns.tokensTotal,
|
tokensTotal: agentRuns.tokensTotal,
|
||||||
notes: agentRuns.notes,
|
notes: agentRuns.notes,
|
||||||
metadata: agentRuns.metadata,
|
metadata: agentRuns.metadata,
|
||||||
|
modelAlias: agentRuns.modelAlias,
|
||||||
|
providerType: agentRuns.providerType,
|
||||||
|
modelId: agentRuns.modelId,
|
||||||
|
modelTier: agentRuns.modelTier,
|
||||||
})
|
})
|
||||||
.from(agentRuns)
|
.from(agentRuns)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
|
|
|
||||||
362
docs/adr-model-routing.md
Normal file
362
docs/adr-model-routing.md
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
# ADR: Model routing and provider registry
|
||||||
|
|
||||||
|
**Status:** Accepted (draft implementation)
|
||||||
|
**Date:** 2026-07-03
|
||||||
|
**Context:** Echodo automation foundation — orchestrator, worker, and mission-control UI without Cursor as the runtime substrate.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Echodo will own a **workspace-scoped model provider registry** and an **extensible role → model routing table**. Every automated pipeline run resolves a concrete provider configuration before inference. Resolution is deterministic, logged on every run, and configurable from Settings without redeploying orchestrator or worker code.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The agent-pipeline defines per-role models via:
|
||||||
|
|
||||||
|
- `model:` frontmatter in `.cursor/agents/role-*.md` (Cursor-only enforcement)
|
||||||
|
- `model_policy.roles` in convoy frontmatter (recommendation only)
|
||||||
|
- `recommended_model` on implementer briefs (optional override)
|
||||||
|
|
||||||
|
That works for manual Cursor sessions. It does **not** work when:
|
||||||
|
|
||||||
|
- An orchestrator dispatches `role-implementer` unattended
|
||||||
|
- Planning uses Claude API and implementation uses local Ollama on a Mac
|
||||||
|
- Operators add providers (OpenAI, Gemini, NVIDIA NIM, LiteLLM gateway) without code changes
|
||||||
|
- Custom roles (e.g. `gate-copilot`, `role-security-auditor`) need routing
|
||||||
|
|
||||||
|
We need one contract that web chat, orchestrator, worker, and MCP logging all share.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Per-role model selection** — conductor/architect on cloud; implementer/auditors on local coder models.
|
||||||
|
2. **Extensible providers** — Anthropic, OpenAI, Google (Gemini), OpenAI-compatible (Ollama, LiteLLM, NVIDIA NIM, Azure OpenAI).
|
||||||
|
3. **Extensible roles** — seed pipeline’s nine L2 roles; allow arbitrary `roleId` rows.
|
||||||
|
4. **Layered overrides** — workspace defaults → convoy `model_policy` → brief `recommended_model`.
|
||||||
|
5. **No secrets in DB** — store `apiKeyEnvVar` names; values live in deploy env (Coolify/Dokploy/Mac).
|
||||||
|
6. **Observable** — every `agent_runs` / `convoy_events` row records alias, provider type, model id, tier.
|
||||||
|
|
||||||
|
## Non-goals (this ADR)
|
||||||
|
|
||||||
|
- Implementing the orchestrator or worker (separate epics).
|
||||||
|
- LiteLLM as a required dependency (optional alias pointing at a gateway URL).
|
||||||
|
- Storing API keys in Postgres or committed YAML.
|
||||||
|
- Replacing `docs/model-routing-policy.md` tier guidance — this ADR **implements** it in Echodo.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Settings → AI → Providers (ModelProviderAlias CRUD) │
|
||||||
|
│ Settings → AI → Role routing (RoleModelRoute CRUD) │
|
||||||
|
└────────────────────────────┬────────────────────────────────┘
|
||||||
|
│
|
||||||
|
resolveModel(workspaceId, roleId, ctx?)
|
||||||
|
│
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
apps/web apps/orchestrator apps/worker
|
||||||
|
(chat, gate (dispatch job (inference
|
||||||
|
co-pilot) with ModelRef) only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolution order (first match wins)
|
||||||
|
|
||||||
|
1. **Brief override** — `recommended_model` alias on `.convoys/<slug>/brief-N-*.md`
|
||||||
|
2. **Convoy override** — `model_policy.roles[roleId]` in `.convoys/<slug>.md`
|
||||||
|
3. **Workspace route** — `role_model_routes` row for `(workspace_id, role_id)`
|
||||||
|
4. **Pipeline template** — shipped defaults (nine L2 roles + `gate-copilot`, `chat-default`)
|
||||||
|
|
||||||
|
If the alias is missing or disabled, fail closed with a structured error — do not silently fall back to a hardcoded model.
|
||||||
|
|
||||||
|
### Escalation
|
||||||
|
|
||||||
|
When `model_policy.escalate_to` is set (convoy) or workspace policy defines escalation:
|
||||||
|
|
||||||
|
- After N failed implementer attempts (default: 2), orchestrator re-dispatches the same brief with the escalation alias.
|
||||||
|
- Log `model_tier` change on the new run.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `model_providers` (per workspace)
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | uuid PK | |
|
||||||
|
| `workspace_id` | uuid FK | Required; all queries filter by this |
|
||||||
|
| `alias` | varchar | Unique per workspace, e.g. `claude-planning` |
|
||||||
|
| `display_name` | varchar | UI label |
|
||||||
|
| `provider_type` | enum | See below |
|
||||||
|
| `model_id` | varchar | Provider-native model string |
|
||||||
|
| `base_url` | text nullable | Required for `openai_compatible` |
|
||||||
|
| `api_key_env_var` | varchar nullable | e.g. `ANTHROPIC_API_KEY` |
|
||||||
|
| `extra_headers` | jsonb nullable | Optional HTTP headers |
|
||||||
|
| `default_params` | jsonb nullable | `temperature`, `maxTokens`, … |
|
||||||
|
| `metadata` | jsonb nullable | `tier`, cost hints for dashboard |
|
||||||
|
| `enabled` | boolean | Default true |
|
||||||
|
| `created_at` / `updated_at` | timestamptz | |
|
||||||
|
|
||||||
|
**`provider_type` enum:**
|
||||||
|
|
||||||
|
| Value | Client | Examples |
|
||||||
|
|-------|--------|----------|
|
||||||
|
| `anthropic` | `@ai-sdk/anthropic` | Claude API |
|
||||||
|
| `openai` | `@ai-sdk/openai` | OpenAI API |
|
||||||
|
| `google` | `@ai-sdk/google` | Gemini |
|
||||||
|
| `openai_compatible` | `@ai-sdk/openai` + `baseURL` | Ollama, LiteLLM, **NVIDIA NIM**, vLLM |
|
||||||
|
| `azure_openai` | `@ai-sdk/openai` + Azure config | Azure deployments |
|
||||||
|
|
||||||
|
Future: `custom` with a registered adapter — not in v1.
|
||||||
|
|
||||||
|
### `role_model_routes` (per workspace)
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | uuid PK | |
|
||||||
|
| `workspace_id` | uuid FK | |
|
||||||
|
| `role_id` | varchar | e.g. `role-implementer`, `gate-copilot` |
|
||||||
|
| `provider_alias_id` | uuid FK → `model_providers.id` | |
|
||||||
|
| `enabled` | boolean | |
|
||||||
|
| `sort_order` | int | UI ordering |
|
||||||
|
| `created_at` / `updated_at` | timestamptz | |
|
||||||
|
|
||||||
|
Unique constraint: `(workspace_id, role_id)`.
|
||||||
|
|
||||||
|
`role_id` is a **string**, not a DB enum — new pipeline roles do not require migrations.
|
||||||
|
|
||||||
|
### Resolved config (runtime, not persisted)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ResolvedModel = {
|
||||||
|
alias: string;
|
||||||
|
providerType: ProviderType;
|
||||||
|
modelId: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey: string; // read from process.env[apiKeyEnvVar] at call time
|
||||||
|
extraHeaders?: Record<string, string>;
|
||||||
|
defaultParams?: { temperature?: number; maxTokens?: number };
|
||||||
|
tier?: "premium" | "fast" | "local" | "auto";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Pure function: `resolveModel(input, db)` lives in `packages/shared` (types + zod) with implementation in `packages/ai` or `packages/shared` (no React, no DB imports in shared — DB fetch in tRPC/orchestrator, pass rows into resolver).
|
||||||
|
|
||||||
|
## Provider examples
|
||||||
|
|
||||||
|
### Local Ollama (Mac)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: qwen-coder
|
||||||
|
provider_type: openai_compatible
|
||||||
|
model_id: qwen2.5-coder:32b
|
||||||
|
base_url: http://192.168.1.42:11434/v1
|
||||||
|
api_key_env_var: OLLAMA_API_KEY # any non-empty string Ollama accepts
|
||||||
|
metadata: { tier: local }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local MLX on Apple Silicon (Mac)
|
||||||
|
|
||||||
|
[MLX](https://github.com/ml-explore/mlx) is Apple's on-device ML framework. Echodo does **not** talk to MLX directly — use an **OpenAI-compatible HTTP server** in front of MLX, same `provider_type: openai_compatible` as Ollama.
|
||||||
|
|
||||||
|
| Server | Package | Default port | When to use |
|
||||||
|
|--------|---------|--------------|-------------|
|
||||||
|
| **mlx_lm.server** | `pip install mlx-lm` | 8080 | Solo dev, single implementer, simplest path |
|
||||||
|
| **mlx-openai-server** | [cubist38/mlx-openai-server](https://github.com/cubist38/mlx-openai-server) | 8000 | **Parallel implementer fan-out** — continuous batching handles concurrent requests better than the reference server |
|
||||||
|
| mlx-lm-server (community) | [0xDaizz/mlx-lm-server](https://github.com/0xDaizz/mlx-lm-server) | 8000 | Alternative if mlx-openai-server doesn't fit |
|
||||||
|
|
||||||
|
**Parallel implementers note:** When the orchestrator dispatches multiple `role-implementer` jobs at once (disjoint briefs per agent-pipeline `slice_dependencies`), a single local inference process can become the bottleneck. Prefer **mlx-openai-server** (or mlx-lm-server with batching) over the minimal `mlx_lm.server` when running 2+ implementers against the same Mac. Ollama can also serve parallel requests but MLX-native models (`mlx-community/*` on Hugging Face) often perform better on Apple Silicon unified memory. You may run **both** Ollama (`:11434`) and an MLX server (`:8000` / `:8080`) as separate registry aliases and route roles accordingly.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: qwen-coder-mlx
|
||||||
|
provider_type: openai_compatible
|
||||||
|
model_id: mlx-community/Qwen2.5-Coder-32B-Instruct-4bit
|
||||||
|
base_url: http://192.168.1.42:8000/v1 # mlx-openai-server; use :8080/v1 for mlx_lm.server
|
||||||
|
api_key_env_var: MLX_API_KEY # placeholder; local servers ignore it
|
||||||
|
metadata: { tier: local, runtime: mlx }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Operator setup (mlx-openai-server example):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Machine-wide install (all repos): ~/mlx-inference/mlx-serve.sh
|
||||||
|
# See ~/mlx-inference/README.md
|
||||||
|
~/mlx-inference/mlx-serve.sh
|
||||||
|
|
||||||
|
# Or manual:
|
||||||
|
pip install mlx-lm mlx-openai-server
|
||||||
|
mlx-openai-server launch \
|
||||||
|
--model-path mlx-community/Qwen2.5-Coder-32B-Instruct-4bit \
|
||||||
|
--model-type lm \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Networking:** Bind `0.0.0.0` when Echodo (Coolify) or `apps/worker` on another host must reach the Mac over LAN. Restrict firewall to homelab subnet. Do not expose raw local inference to the public internet.
|
||||||
|
|
||||||
|
**v1 registry:** No `provider_type: mlx` — HTTP gateway only. Native MLX SDK in `apps/worker` is out of scope.
|
||||||
|
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: claude-planning
|
||||||
|
provider_type: anthropic
|
||||||
|
model_id: claude-sonnet-4-20250514
|
||||||
|
api_key_env_var: ANTHROPIC_API_KEY
|
||||||
|
metadata: { tier: premium }
|
||||||
|
```
|
||||||
|
|
||||||
|
### NVIDIA NIM
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: nim-llama-70b
|
||||||
|
provider_type: openai_compatible
|
||||||
|
model_id: meta/llama-3.1-70b-instruct
|
||||||
|
base_url: https://integrate.api.nvidia.com/v1
|
||||||
|
api_key_env_var: NVIDIA_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: gemini-flash
|
||||||
|
provider_type: google
|
||||||
|
model_id: gemini-2.0-flash
|
||||||
|
api_key_env_var: GOOGLE_GENERATIVE_AI_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### LiteLLM gateway (optional)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: litellm-gateway
|
||||||
|
provider_type: openai_compatible
|
||||||
|
model_id: qwen2.5-coder:32b # or per-role model_id in route table
|
||||||
|
base_url: http://host:4000/v1
|
||||||
|
api_key_env_var: LITELLM_MASTER_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default role routing (seed template)
|
||||||
|
|
||||||
|
Shipped when a workspace enables pipeline integration:
|
||||||
|
|
||||||
|
| role_id | Suggested alias | tier |
|
||||||
|
|---------|-----------------|------|
|
||||||
|
| `role-conductor` | `claude-planning` | premium |
|
||||||
|
| `role-architect` | `claude-planning` | premium |
|
||||||
|
| `role-ia-architect` | `claude-planning` | premium |
|
||||||
|
| `role-ux-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
|
||||||
|
| `role-implementer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
|
||||||
|
| `role-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
|
||||||
|
| `role-design-system-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
|
||||||
|
| `role-a11y-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
|
||||||
|
| `role-doc-writer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
|
||||||
|
| `gate-copilot` | `claude-planning` | premium |
|
||||||
|
| `chat-default` | workspace choice | — |
|
||||||
|
|
||||||
|
Operators remap aliases in Settings; seed is not immutable.
|
||||||
|
|
||||||
|
## Convoy / brief integration
|
||||||
|
|
||||||
|
Convoy frontmatter uses **registry aliases**, not Cursor model slugs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_policy:
|
||||||
|
roles:
|
||||||
|
role-conductor: claude-planning
|
||||||
|
role-architect: claude-planning
|
||||||
|
role-implementer: qwen-coder
|
||||||
|
escalate_to: claude-planning
|
||||||
|
```
|
||||||
|
|
||||||
|
Brief frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: fast
|
||||||
|
```
|
||||||
|
|
||||||
|
Importer/parser for convoy files is out of scope for the first epic; orchestrator reads frontmatter when dispatching.
|
||||||
|
|
||||||
|
## API surface (tRPC)
|
||||||
|
|
||||||
|
Router: `ai.models` (or `pipeline.models`)
|
||||||
|
|
||||||
|
| Procedure | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `listProviders` | Paginated; workspace-scoped |
|
||||||
|
| `createProvider` / `updateProvider` / `deleteProvider` | Admin-only |
|
||||||
|
| `testProvider` | One-token completion; returns latency + error detail |
|
||||||
|
| `listRoleRoutes` | All routes for workspace |
|
||||||
|
| `upsertRoleRoute` | Set role → alias; supports custom `roleId` |
|
||||||
|
| `deleteRoleRoute` | Remove custom role |
|
||||||
|
| `resolveForRole` | Debug: given `roleId` + optional convoy/brief context |
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- `/{workspaceSlug}/settings/ai/providers` — provider table + add/edit dialog + test button
|
||||||
|
- `/{workspaceSlug}/settings/ai/role-routing` — role → alias table + add custom role
|
||||||
|
- Link from convoy/gate screens: “Model for this step: `qwen-coder` (change)”
|
||||||
|
|
||||||
|
## Logging contract
|
||||||
|
|
||||||
|
Extend `agent_runs` (and future `convoy_events`) with:
|
||||||
|
|
||||||
|
- `model_alias` varchar nullable
|
||||||
|
- `provider_type` varchar nullable
|
||||||
|
- `model_id` varchar nullable
|
||||||
|
- `model_tier` varchar nullable
|
||||||
|
|
||||||
|
`complete_task` MCP tool accepts optional `modelAlias` / `modelTier` for close-out logging.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Provider CRUD: workspace admin only (same bar as `updateWorkflowPrompt`).
|
||||||
|
- `testProvider` runs server-side; rate-limit per workspace.
|
||||||
|
- Never return resolved API keys to the browser — only alias and env var **name**.
|
||||||
|
- Worker/orchestrator processes must have env vars for aliases they use.
|
||||||
|
|
||||||
|
## Relationship to other ADRs / plans
|
||||||
|
|
||||||
|
| Artifact | Relationship |
|
||||||
|
|----------|----------------|
|
||||||
|
| `docs/model-routing-policy.md` | Tier guidance; Cursor-era slugs migrate to aliases |
|
||||||
|
| `plans/Plan-agent-coordination` | `claim_task` / `complete_task` log model fields |
|
||||||
|
| `plans/Plan-automation-foundation` | Implements this ADR (model registry epic) |
|
||||||
|
| Future orchestrator epic | Calls `resolveModel` on every dispatch |
|
||||||
|
| Future worker epic | Consumes `ResolvedModel` only |
|
||||||
|
|
||||||
|
## Migration from Cursor slugs
|
||||||
|
|
||||||
|
Legacy convoys may reference `claude-4.6-opus-high-thinking` or `composer-2.5-fast`. Optional alias map in workspace metadata:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"legacy_model_aliases": {
|
||||||
|
"claude-4.6-opus-high-thinking": "claude-planning",
|
||||||
|
"composer-2.5-fast": "qwen-coder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- Single place to change Claude vs Qwen vs NIM without code deploys.
|
||||||
|
- Orchestrator and worker stay provider-agnostic.
|
||||||
|
- Extensible roles support pipeline evolution.
|
||||||
|
|
||||||
|
**Negative**
|
||||||
|
|
||||||
|
- Operators must configure providers before automation runs (mitigated: test button + clear errors).
|
||||||
|
- Two sources of truth for convoy overrides (markdown + DB) until convoy lifecycle MCP lands — resolution order is explicit.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Repo-level `echodo.models.yml`** — mirror workspace config in git for bootstrap? Deferred; DB is v1 source.
|
||||||
|
2. **Per-user overrides** — deferred; workspace-level only for v1.
|
||||||
|
3. **Budget caps per alias** — defer to LiteLLM or Phase 4 workers.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md`
|
||||||
|
- Agent-pipeline v0.4 plan: convoy lifecycle + Echodo integration
|
||||||
|
- Vercel AI SDK provider packages in `packages/ai`
|
||||||
|
- MLX: [ml-explore/mlx](https://github.com/ml-explore/mlx), [mlx-lm SERVER.md](https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/SERVER.md), [mlx-openai-server](https://github.com/cubist38/mlx-openai-server)
|
||||||
|
- Free-tier discovery: [cheahjs/free-llm-api-resources](https://github.com/cheahjs/free-llm-api-resources), [mnfst/awesome-free-llm-apis](https://github.com/mnfst/awesome-free-llm-apis)
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"ai": "^4.0.0",
|
"ai": "^4.0.0",
|
||||||
"@ai-sdk/openai": "^1.0.0",
|
"@ai-sdk/openai": "^1.0.0",
|
||||||
"@ai-sdk/anthropic": "^1.0.0",
|
"@ai-sdk/anthropic": "^1.0.0",
|
||||||
|
"@ai-sdk/google": "^1.0.0",
|
||||||
"@tasks/shared": "workspace:*",
|
"@tasks/shared": "workspace:*",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ export {
|
||||||
type OpenAIClientResult,
|
type OpenAIClientResult,
|
||||||
type OpenAIModelId,
|
type OpenAIModelId,
|
||||||
} from "./providers/openai";
|
} from "./providers/openai";
|
||||||
|
export {
|
||||||
|
createLanguageModelFromResolved,
|
||||||
|
type CreateLanguageModelResult,
|
||||||
|
} from "./providers/resolve-client";
|
||||||
export {
|
export {
|
||||||
EDITOR_SYSTEM_PROMPT,
|
EDITOR_SYSTEM_PROMPT,
|
||||||
GENERAL_SYSTEM_PROMPT,
|
GENERAL_SYSTEM_PROMPT,
|
||||||
|
|
|
||||||
48
packages/ai/src/providers/resolve-client.ts
Normal file
48
packages/ai/src/providers/resolve-client.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||||
|
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||||
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
|
import type { LanguageModel } from "ai";
|
||||||
|
|
||||||
|
import type { ResolvedModel } from "@tasks/shared/model-routing";
|
||||||
|
|
||||||
|
export type CreateLanguageModelResult =
|
||||||
|
| { ok: true; model: LanguageModel }
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Vercel AI SDK `LanguageModel` from a resolved registry config.
|
||||||
|
* Callers must pass a fully resolved `ResolvedModel` (including apiKey).
|
||||||
|
*/
|
||||||
|
export function createLanguageModelFromResolved(
|
||||||
|
resolved: ResolvedModel,
|
||||||
|
): CreateLanguageModelResult {
|
||||||
|
try {
|
||||||
|
switch (resolved.providerType) {
|
||||||
|
case "anthropic": {
|
||||||
|
const anthropic = createAnthropic({ apiKey: resolved.apiKey });
|
||||||
|
return { ok: true, model: anthropic(resolved.modelId) };
|
||||||
|
}
|
||||||
|
case "openai":
|
||||||
|
case "openai_compatible":
|
||||||
|
case "azure_openai": {
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey: resolved.apiKey,
|
||||||
|
baseURL: resolved.baseUrl,
|
||||||
|
});
|
||||||
|
return { ok: true, model: openai(resolved.modelId) };
|
||||||
|
}
|
||||||
|
case "google": {
|
||||||
|
const google = createGoogleGenerativeAI({ apiKey: resolved.apiKey });
|
||||||
|
return { ok: true, model: google(resolved.modelId) };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `Unsupported provider type: ${resolved.providerType as string}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ok: false, reason: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/database/migrations/0010_legal_stryfe.sql
Normal file
36
packages/database/migrations/0010_legal_stryfe.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
CREATE TABLE "model_providers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"alias" varchar(120) NOT NULL,
|
||||||
|
"display_name" varchar(200) NOT NULL,
|
||||||
|
"provider_type" varchar(40) NOT NULL,
|
||||||
|
"model_id" varchar(200) NOT NULL,
|
||||||
|
"base_url" text,
|
||||||
|
"api_key_env_var" varchar(120),
|
||||||
|
"extra_headers" jsonb,
|
||||||
|
"default_params" jsonb,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "role_model_routes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"role_id" varchar(120) NOT NULL,
|
||||||
|
"provider_alias_id" uuid NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "model_providers" ADD CONSTRAINT "model_providers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_provider_alias_id_model_providers_id_fk" FOREIGN KEY ("provider_alias_id") REFERENCES "public"."model_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "model_providers_workspace_id_idx" ON "model_providers" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "model_providers_workspace_id_alias_unique" ON "model_providers" USING btree ("workspace_id","alias");--> statement-breakpoint
|
||||||
|
CREATE INDEX "role_model_routes_workspace_id_idx" ON "role_model_routes" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "role_model_routes_workspace_id_role_id_unique" ON "role_model_routes" USING btree ("workspace_id","role_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "role_model_routes_provider_alias_id_idx" ON "role_model_routes" USING btree ("provider_alias_id");
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE "agent_runs" ADD COLUMN "model_alias" varchar(120);--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_runs" ADD COLUMN "provider_type" varchar(40);--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_runs" ADD COLUMN "model_id" varchar(200);--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_runs" ADD COLUMN "model_tier" varchar(20);
|
||||||
3351
packages/database/migrations/meta/0010_snapshot.json
Normal file
3351
packages/database/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3375
packages/database/migrations/meta/0011_snapshot.json
Normal file
3375
packages/database/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,20 @@
|
||||||
"when": 1780455865684,
|
"when": 1780455865684,
|
||||||
"tag": "0009_loving_rogue",
|
"tag": "0009_loving_rogue",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783450794221,
|
||||||
|
"tag": "0010_legal_stryfe",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783451144455,
|
||||||
|
"tag": "0011_cool_captain_america",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./schema": "./src/schema/index.ts",
|
"./schema": "./src/schema/index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
"./markdown-backlog": "./src/markdown-backlog/index.ts"
|
"./markdown-backlog": "./src/markdown-backlog/index.ts",
|
||||||
|
"./model-routing": "./src/model-routing/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
"import:markdown-backlog": "tsx src/scripts/import-markdown-backlog.ts"
|
"import:markdown-backlog": "tsx src/scripts/import-markdown-backlog.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tasks/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"yaml": "^2.8.3",
|
"yaml": "^2.8.3",
|
||||||
|
|
|
||||||
2
packages/database/src/model-routing/index.ts
Normal file
2
packages/database/src/model-routing/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { seedDefaultRoleRoutes } from "./seed-default-role-routes";
|
||||||
|
export type { SeedDefaultRoleRoutesResult } from "./seed-default-role-routes";
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
|
|
||||||
|
import { PIPELINE_DEFAULT_ROLE_ROUTES } from "@tasks/shared/model-routing";
|
||||||
|
|
||||||
|
import type * as schema from "../schema/index";
|
||||||
|
import { modelProviders, roleModelRoutes } from "../schema/index";
|
||||||
|
|
||||||
|
type Database = PostgresJsDatabase<typeof schema>;
|
||||||
|
|
||||||
|
export type SeedDefaultRoleRoutesResult = {
|
||||||
|
inserted: number;
|
||||||
|
skipped: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts pipeline default role → alias routes for a workspace when matching
|
||||||
|
* provider aliases already exist. Does not create providers.
|
||||||
|
*/
|
||||||
|
export async function seedDefaultRoleRoutes(
|
||||||
|
db: Database,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<SeedDefaultRoleRoutesResult> {
|
||||||
|
const providers = await db
|
||||||
|
.select({ id: modelProviders.id, alias: modelProviders.alias })
|
||||||
|
.from(modelProviders)
|
||||||
|
.where(eq(modelProviders.workspaceId, workspaceId));
|
||||||
|
|
||||||
|
const aliasToId = new Map(providers.map((row) => [row.alias, row.id]));
|
||||||
|
let inserted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let sortOrder = 0;
|
||||||
|
|
||||||
|
for (const [roleId, alias] of Object.entries(PIPELINE_DEFAULT_ROLE_ROUTES)) {
|
||||||
|
const providerId = aliasToId.get(alias);
|
||||||
|
if (!providerId) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: roleModelRoutes.id })
|
||||||
|
.from(roleModelRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleModelRoutes.workspaceId, workspaceId),
|
||||||
|
eq(roleModelRoutes.roleId, roleId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(roleModelRoutes).values({
|
||||||
|
workspaceId,
|
||||||
|
roleId,
|
||||||
|
providerAliasId: providerId,
|
||||||
|
sortOrder: sortOrder++,
|
||||||
|
});
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted, skipped };
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,10 @@ export const agentRuns = pgTable(
|
||||||
tokensTotal: integer("tokens_total"),
|
tokensTotal: integer("tokens_total"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
||||||
|
modelAlias: varchar("model_alias", { length: 120 }),
|
||||||
|
providerType: varchar("provider_type", { length: 40 }),
|
||||||
|
modelId: varchar("model_id", { length: 200 }),
|
||||||
|
modelTier: varchar("model_tier", { length: 20 }),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on(
|
workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on(
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,5 @@ export * from "./markdown_backlog";
|
||||||
export * from "./cursor_sync";
|
export * from "./cursor_sync";
|
||||||
export * from "./audit";
|
export * from "./audit";
|
||||||
export * from "./agent_runs";
|
export * from "./agent_runs";
|
||||||
|
export * from "./model_providers";
|
||||||
|
export * from "./role_model_routes";
|
||||||
|
|
|
||||||
51
packages/database/src/schema/model_providers.ts
Normal file
51
packages/database/src/schema/model_providers.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
jsonb,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
integer,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
|
/** Workspace-scoped model provider alias — secrets live in env vars, not here. */
|
||||||
|
export const modelProviders = pgTable(
|
||||||
|
"model_providers",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
workspaceId: uuid("workspace_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
|
alias: varchar("alias", { length: 120 }).notNull(),
|
||||||
|
displayName: varchar("display_name", { length: 200 }).notNull(),
|
||||||
|
providerType: varchar("provider_type", { length: 40 }).notNull(),
|
||||||
|
modelId: varchar("model_id", { length: 200 }).notNull(),
|
||||||
|
baseUrl: text("base_url"),
|
||||||
|
apiKeyEnvVar: varchar("api_key_env_var", { length: 120 }),
|
||||||
|
extraHeaders: jsonb("extra_headers").$type<Record<string, string> | null>(),
|
||||||
|
defaultParams: jsonb("default_params").$type<{
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
} | null>(),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workspaceIdIdx: index("model_providers_workspace_id_idx").on(table.workspaceId),
|
||||||
|
workspaceAliasUnique: uniqueIndex("model_providers_workspace_id_alias_unique").on(
|
||||||
|
table.workspaceId,
|
||||||
|
table.alias,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -19,6 +19,8 @@ import { cursorSyncMappings } from "./cursor_sync";
|
||||||
import { workspaces, workspaceInvites } from "./workspaces";
|
import { workspaces, workspaceInvites } from "./workspaces";
|
||||||
import { auditLog } from "./audit";
|
import { auditLog } from "./audit";
|
||||||
import { agentRuns } from "./agent_runs";
|
import { agentRuns } from "./agent_runs";
|
||||||
|
import { modelProviders } from "./model_providers";
|
||||||
|
import { roleModelRoutes } from "./role_model_routes";
|
||||||
|
|
||||||
export const objectRelations = pgTable(
|
export const objectRelations = pgTable(
|
||||||
"object_relations",
|
"object_relations",
|
||||||
|
|
@ -116,6 +118,8 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
|
||||||
objectTypeDefs: many(objectTypeDefs),
|
objectTypeDefs: many(objectTypeDefs),
|
||||||
propertyDefinitions: many(propertyDefinitions),
|
propertyDefinitions: many(propertyDefinitions),
|
||||||
markdownBacklogItems: many(markdownBacklogItems),
|
markdownBacklogItems: many(markdownBacklogItems),
|
||||||
|
modelProviders: many(modelProviders),
|
||||||
|
roleModelRoutes: many(roleModelRoutes),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const objectsRelations = relations(objects, ({ one, many }) => ({
|
export const objectsRelations = relations(objects, ({ one, many }) => ({
|
||||||
|
|
@ -264,3 +268,22 @@ export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one
|
||||||
references: [markdownBacklogItems.id],
|
references: [markdownBacklogItems.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const modelProvidersRelations = relations(modelProviders, ({ one, many }) => ({
|
||||||
|
workspace: one(workspaces, {
|
||||||
|
fields: [modelProviders.workspaceId],
|
||||||
|
references: [workspaces.id],
|
||||||
|
}),
|
||||||
|
roleRoutes: many(roleModelRoutes),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) => ({
|
||||||
|
workspace: one(workspaces, {
|
||||||
|
fields: [roleModelRoutes.workspaceId],
|
||||||
|
references: [workspaces.id],
|
||||||
|
}),
|
||||||
|
provider: one(modelProviders, {
|
||||||
|
fields: [roleModelRoutes.providerAliasId],
|
||||||
|
references: [modelProviders.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
45
packages/database/src/schema/role_model_routes.ts
Normal file
45
packages/database/src/schema/role_model_routes.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { workspaces } from "./workspaces";
|
||||||
|
import { modelProviders } from "./model_providers";
|
||||||
|
|
||||||
|
/** Maps pipeline role_id strings to a workspace provider alias row. */
|
||||||
|
export const roleModelRoutes = pgTable(
|
||||||
|
"role_model_routes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
workspaceId: uuid("workspace_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
|
roleId: varchar("role_id", { length: 120 }).notNull(),
|
||||||
|
providerAliasId: uuid("provider_alias_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => modelProviders.id, { onDelete: "cascade" }),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workspaceIdIdx: index("role_model_routes_workspace_id_idx").on(table.workspaceId),
|
||||||
|
workspaceRoleUnique: uniqueIndex(
|
||||||
|
"role_model_routes_workspace_id_role_id_unique",
|
||||||
|
).on(table.workspaceId, table.roleId),
|
||||||
|
providerAliasIdIdx: index("role_model_routes_provider_alias_id_idx").on(
|
||||||
|
table.providerAliasId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./model-routing": "./src/model-routing/index.ts",
|
||||||
"./types": "./src/types/index.ts",
|
"./types": "./src/types/index.ts",
|
||||||
"./utils": "./src/utils/index.ts"
|
"./utils": "./src/utils/index.ts"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
|
export * from "./model-routing";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
|
|
||||||
28
packages/shared/src/model-routing/constants.ts
Normal file
28
packages/shared/src/model-routing/constants.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Shipped pipeline defaults — alias *names*, not provider UUIDs. Operators remap
|
||||||
|
* aliases in Settings; these are fallbacks when no workspace route exists.
|
||||||
|
*/
|
||||||
|
export const PIPELINE_DEFAULT_ROLE_ROUTES: Readonly<Record<string, string>> = {
|
||||||
|
"role-conductor": "claude-planning",
|
||||||
|
"role-architect": "claude-planning",
|
||||||
|
"role-ia-architect": "claude-planning",
|
||||||
|
"role-ux-reviewer": "qwen-coder",
|
||||||
|
"role-implementer": "qwen-coder",
|
||||||
|
"role-reviewer": "qwen-coder",
|
||||||
|
"role-design-system-auditor": "qwen-coder",
|
||||||
|
"role-a11y-auditor": "qwen-coder",
|
||||||
|
"role-doc-writer": "qwen-coder",
|
||||||
|
"gate-copilot": "claude-planning",
|
||||||
|
"chat-default": "qwen-coder",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cursor-era model slugs → registry alias names (workspace may override). */
|
||||||
|
export const LEGACY_CURSOR_MODEL_ALIASES: Readonly<Record<string, string>> = {
|
||||||
|
"claude-4.6-opus-high-thinking": "claude-planning",
|
||||||
|
"claude-4.6-sonnet-medium-thinking": "claude-planning",
|
||||||
|
"claude-opus-4-8-thinking-high": "claude-planning",
|
||||||
|
"claude-sonnet-5-thinking-medium": "claude-planning",
|
||||||
|
"composer-2.5-fast": "qwen-coder",
|
||||||
|
"composer-2.5": "qwen-coder",
|
||||||
|
auto: "qwen-coder",
|
||||||
|
};
|
||||||
28
packages/shared/src/model-routing/index.ts
Normal file
28
packages/shared/src/model-routing/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export {
|
||||||
|
LEGACY_CURSOR_MODEL_ALIASES,
|
||||||
|
PIPELINE_DEFAULT_ROLE_ROUTES,
|
||||||
|
} from "./constants";
|
||||||
|
export {
|
||||||
|
convoyModelPolicySchema,
|
||||||
|
modelProviderSchema,
|
||||||
|
modelTierSchema,
|
||||||
|
providerTypeSchema,
|
||||||
|
resolvedModelSchema,
|
||||||
|
roleModelRouteSchema,
|
||||||
|
type ConvoyModelPolicy,
|
||||||
|
type ModelProvider,
|
||||||
|
type ModelTier,
|
||||||
|
type ProviderType,
|
||||||
|
type ResolvedModel,
|
||||||
|
type RoleModelRoute,
|
||||||
|
} from "./schemas";
|
||||||
|
export {
|
||||||
|
resolveModel,
|
||||||
|
resolveModelErrorCodeSchema,
|
||||||
|
type ResolveModelError,
|
||||||
|
type ResolveModelErrorCode,
|
||||||
|
type ResolveModelFailure,
|
||||||
|
type ResolveModelInput,
|
||||||
|
type ResolveModelResult,
|
||||||
|
type ResolveModelSuccess,
|
||||||
|
} from "./resolve-model";
|
||||||
172
packages/shared/src/model-routing/resolve-model.test.ts
Normal file
172
packages/shared/src/model-routing/resolve-model.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { LEGACY_CURSOR_MODEL_ALIASES } from "./constants";
|
||||||
|
import type { ModelProvider, RoleModelRoute } from "./schemas";
|
||||||
|
import { resolveModel } from "./resolve-model";
|
||||||
|
|
||||||
|
const WORKSPACE_ID = "379ecb2f-6db9-4c53-93ad-72c594eb44a5";
|
||||||
|
|
||||||
|
function provider(
|
||||||
|
overrides: Partial<ModelProvider> & Pick<ModelProvider, "alias" | "providerType" | "modelId">,
|
||||||
|
): ModelProvider {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "11111111-1111-1111-1111-111111111111",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
displayName: overrides.displayName ?? overrides.alias,
|
||||||
|
enabled: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const claude = provider({
|
||||||
|
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
alias: "claude-planning",
|
||||||
|
providerType: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-20250514",
|
||||||
|
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
||||||
|
metadata: { tier: "premium" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const qwen = provider({
|
||||||
|
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||||
|
alias: "qwen-coder",
|
||||||
|
providerType: "openai_compatible",
|
||||||
|
modelId: "qwen2.5-coder:32b",
|
||||||
|
baseUrl: "http://127.0.0.1:11434/v1",
|
||||||
|
apiKeyEnvVar: "OLLAMA_API_KEY",
|
||||||
|
metadata: { tier: "local" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const routes: RoleModelRoute[] = [
|
||||||
|
{
|
||||||
|
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
roleId: "role-implementer",
|
||||||
|
providerAliasId: qwen.id,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getApiKey = (name: string) =>
|
||||||
|
({ ANTHROPIC_API_KEY: "sk-ant-test", OLLAMA_API_KEY: "ollama" })[name];
|
||||||
|
|
||||||
|
describe("resolveModel", () => {
|
||||||
|
it("prefers brief recommended_model over convoy, workspace, and default", () => {
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-implementer",
|
||||||
|
providers: [claude, qwen],
|
||||||
|
routes,
|
||||||
|
convoyPolicy: { roles: { "role-implementer": "qwen-coder" } },
|
||||||
|
briefRecommendedModel: "claude-planning",
|
||||||
|
getApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.source).toBe("brief");
|
||||||
|
expect(result.model.alias).toBe("claude-planning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers convoy policy over workspace route and pipeline default", () => {
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-implementer",
|
||||||
|
providers: [claude, qwen],
|
||||||
|
routes,
|
||||||
|
convoyPolicy: { roles: { "role-implementer": "claude-planning" } },
|
||||||
|
getApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.source).toBe("convoy");
|
||||||
|
expect(result.model.alias).toBe("claude-planning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses workspace route when convoy and brief are absent", () => {
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-implementer",
|
||||||
|
providers: [claude, qwen],
|
||||||
|
routes,
|
||||||
|
getApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.source).toBe("workspace");
|
||||||
|
expect(result.model.alias).toBe("qwen-coder");
|
||||||
|
expect(result.model.tier).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to pipeline default role routes", () => {
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-architect",
|
||||||
|
providers: [claude, qwen],
|
||||||
|
routes: [],
|
||||||
|
getApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.source).toBe("default");
|
||||||
|
expect(result.model.alias).toBe("claude-planning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ALIAS_NOT_RESOLVED when no layer matches", () => {
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-custom-unknown",
|
||||||
|
providers: [claude, qwen],
|
||||||
|
routes: [],
|
||||||
|
defaultRoutes: {},
|
||||||
|
getApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.code).toBe("ALIAS_NOT_RESOLVED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns PROVIDER_DISABLED when alias maps to a disabled provider", () => {
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-implementer",
|
||||||
|
providers: [{ ...qwen, enabled: false }],
|
||||||
|
routes: [],
|
||||||
|
briefRecommendedModel: "qwen-coder",
|
||||||
|
getApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.code).toBe("PROVIDER_DISABLED");
|
||||||
|
expect(result.error.alias).toBe("qwen-coder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaps legacy Cursor model slugs before catalog lookup", () => {
|
||||||
|
const legacySlug = "composer-2.5-fast";
|
||||||
|
expect(LEGACY_CURSOR_MODEL_ALIASES[legacySlug]).toBe("qwen-coder");
|
||||||
|
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-implementer",
|
||||||
|
providers: [qwen],
|
||||||
|
routes: [],
|
||||||
|
briefRecommendedModel: legacySlug,
|
||||||
|
getApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.resolvedAlias).toBe("qwen-coder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns API_KEY_MISSING when required env var is absent", () => {
|
||||||
|
const result = resolveModel({
|
||||||
|
roleId: "role-architect",
|
||||||
|
providers: [claude],
|
||||||
|
routes: [],
|
||||||
|
getApiKey: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.code).toBe("API_KEY_MISSING");
|
||||||
|
});
|
||||||
|
});
|
||||||
213
packages/shared/src/model-routing/resolve-model.ts
Normal file
213
packages/shared/src/model-routing/resolve-model.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import {
|
||||||
|
LEGACY_CURSOR_MODEL_ALIASES,
|
||||||
|
PIPELINE_DEFAULT_ROLE_ROUTES,
|
||||||
|
} from "./constants";
|
||||||
|
import type {
|
||||||
|
ConvoyModelPolicy,
|
||||||
|
ModelProvider,
|
||||||
|
ModelTier,
|
||||||
|
ResolvedModel,
|
||||||
|
RoleModelRoute,
|
||||||
|
} from "./schemas";
|
||||||
|
import { resolvedModelSchema } from "./schemas";
|
||||||
|
|
||||||
|
export const resolveModelErrorCodeSchema = [
|
||||||
|
"ALIAS_NOT_RESOLVED",
|
||||||
|
"PROVIDER_NOT_FOUND",
|
||||||
|
"PROVIDER_DISABLED",
|
||||||
|
"BASE_URL_REQUIRED",
|
||||||
|
"API_KEY_MISSING",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ResolveModelErrorCode = (typeof resolveModelErrorCodeSchema)[number];
|
||||||
|
|
||||||
|
export type ResolveModelError = {
|
||||||
|
code: ResolveModelErrorCode;
|
||||||
|
message: string;
|
||||||
|
alias?: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveModelSuccess = {
|
||||||
|
ok: true;
|
||||||
|
model: ResolvedModel;
|
||||||
|
source: "brief" | "convoy" | "workspace" | "default";
|
||||||
|
resolvedAlias: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveModelFailure = {
|
||||||
|
ok: false;
|
||||||
|
error: ResolveModelError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveModelResult = ResolveModelSuccess | ResolveModelFailure;
|
||||||
|
|
||||||
|
export type ResolveModelInput = {
|
||||||
|
roleId: string;
|
||||||
|
providers: ModelProvider[];
|
||||||
|
routes: RoleModelRoute[];
|
||||||
|
convoyPolicy?: ConvoyModelPolicy;
|
||||||
|
briefRecommendedModel?: string;
|
||||||
|
/** Workspace metadata `legacy_model_aliases` — checked after global legacy map. */
|
||||||
|
legacyAliases?: Record<string, string>;
|
||||||
|
getApiKey: (envVarName: string) => string | undefined;
|
||||||
|
defaultRoutes?: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAlias(
|
||||||
|
rawAlias: string,
|
||||||
|
legacyAliases?: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const trimmed = rawAlias.trim();
|
||||||
|
return (
|
||||||
|
legacyAliases?.[trimmed] ??
|
||||||
|
LEGACY_CURSOR_MODEL_ALIASES[trimmed] ??
|
||||||
|
trimmed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTier(metadata: ModelProvider["metadata"]): ModelTier | undefined {
|
||||||
|
if (!metadata || typeof metadata !== "object") return undefined;
|
||||||
|
const tier = (metadata as { tier?: unknown }).tier;
|
||||||
|
if (
|
||||||
|
tier === "premium" ||
|
||||||
|
tier === "fast" ||
|
||||||
|
tier === "local" ||
|
||||||
|
tier === "auto"
|
||||||
|
) {
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAlias(
|
||||||
|
input: ResolveModelInput,
|
||||||
|
): { alias: string; source: ResolveModelSuccess["source"] } | null {
|
||||||
|
const brief = input.briefRecommendedModel?.trim();
|
||||||
|
if (brief) {
|
||||||
|
return { alias: brief, source: "brief" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const convoyAlias = input.convoyPolicy?.roles?.[input.roleId]?.trim();
|
||||||
|
if (convoyAlias) {
|
||||||
|
return { alias: convoyAlias, source: "convoy" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = input.routes.find(
|
||||||
|
(row) => row.roleId === input.roleId && row.enabled,
|
||||||
|
);
|
||||||
|
if (route) {
|
||||||
|
const provider = input.providers.find((p) => p.id === route.providerAliasId);
|
||||||
|
if (provider?.alias) {
|
||||||
|
return { alias: provider.alias, source: "workspace" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = input.defaultRoutes ?? PIPELINE_DEFAULT_ROLE_ROUTES;
|
||||||
|
const fallback = defaults[input.roleId]?.trim();
|
||||||
|
if (fallback) {
|
||||||
|
return { alias: fallback, source: "default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(
|
||||||
|
code: ResolveModelErrorCode,
|
||||||
|
message: string,
|
||||||
|
roleId: string,
|
||||||
|
alias?: string,
|
||||||
|
): ResolveModelFailure {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { code, message, roleId, alias },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure resolver — callers load workspace provider/route rows and pass plain
|
||||||
|
* objects. Resolution order: brief → convoy → workspace route → pipeline default.
|
||||||
|
*/
|
||||||
|
export function resolveModel(input: ResolveModelInput): ResolveModelResult {
|
||||||
|
const picked = pickAlias(input);
|
||||||
|
if (!picked) {
|
||||||
|
return fail(
|
||||||
|
"ALIAS_NOT_RESOLVED",
|
||||||
|
`No model alias resolved for role "${input.roleId}"`,
|
||||||
|
input.roleId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedAlias = normalizeAlias(picked.alias, input.legacyAliases);
|
||||||
|
const provider = input.providers.find((p) => p.alias === resolvedAlias);
|
||||||
|
if (!provider) {
|
||||||
|
return fail(
|
||||||
|
"PROVIDER_NOT_FOUND",
|
||||||
|
`Provider alias "${resolvedAlias}" is not registered in this workspace`,
|
||||||
|
input.roleId,
|
||||||
|
resolvedAlias,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.enabled) {
|
||||||
|
return fail(
|
||||||
|
"PROVIDER_DISABLED",
|
||||||
|
`Provider alias "${resolvedAlias}" is disabled`,
|
||||||
|
input.roleId,
|
||||||
|
resolvedAlias,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.providerType === "openai_compatible" && !provider.baseUrl) {
|
||||||
|
return fail(
|
||||||
|
"BASE_URL_REQUIRED",
|
||||||
|
`Provider "${resolvedAlias}" requires baseUrl for openai_compatible`,
|
||||||
|
input.roleId,
|
||||||
|
resolvedAlias,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiKey = "";
|
||||||
|
if (provider.apiKeyEnvVar) {
|
||||||
|
const value = input.getApiKey(provider.apiKeyEnvVar);
|
||||||
|
if (value === undefined || value.length === 0) {
|
||||||
|
return fail(
|
||||||
|
"API_KEY_MISSING",
|
||||||
|
`Environment variable "${provider.apiKeyEnvVar}" is not set for provider "${resolvedAlias}"`,
|
||||||
|
input.roleId,
|
||||||
|
resolvedAlias,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
apiKey = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model: ResolvedModel = {
|
||||||
|
alias: resolvedAlias,
|
||||||
|
providerType: provider.providerType,
|
||||||
|
modelId: provider.modelId,
|
||||||
|
apiKey,
|
||||||
|
...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
|
||||||
|
...(provider.extraHeaders ? { extraHeaders: provider.extraHeaders } : {}),
|
||||||
|
...(provider.defaultParams ? { defaultParams: provider.defaultParams } : {}),
|
||||||
|
...(readTier(provider.metadata)
|
||||||
|
? { tier: readTier(provider.metadata) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = resolvedModelSchema.safeParse(model);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(
|
||||||
|
"PROVIDER_NOT_FOUND",
|
||||||
|
parsed.error.issues.map((i) => i.message).join("; "),
|
||||||
|
input.roleId,
|
||||||
|
resolvedAlias,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
model: parsed.data,
|
||||||
|
source: picked.source,
|
||||||
|
resolvedAlias,
|
||||||
|
};
|
||||||
|
}
|
||||||
74
packages/shared/src/model-routing/schemas.ts
Normal file
74
packages/shared/src/model-routing/schemas.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const providerTypeSchema = z.enum([
|
||||||
|
"anthropic",
|
||||||
|
"openai",
|
||||||
|
"google",
|
||||||
|
"openai_compatible",
|
||||||
|
"azure_openai",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ProviderType = z.infer<typeof providerTypeSchema>;
|
||||||
|
|
||||||
|
export const modelTierSchema = z.enum(["premium", "fast", "local", "auto"]);
|
||||||
|
|
||||||
|
export type ModelTier = z.infer<typeof modelTierSchema>;
|
||||||
|
|
||||||
|
export const modelProviderSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspaceId: z.string().uuid(),
|
||||||
|
alias: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
providerType: providerTypeSchema,
|
||||||
|
modelId: z.string().min(1),
|
||||||
|
baseUrl: z.string().url().nullable().optional(),
|
||||||
|
apiKeyEnvVar: z.string().min(1).nullable().optional(),
|
||||||
|
extraHeaders: z.record(z.string()).nullable().optional(),
|
||||||
|
defaultParams: z
|
||||||
|
.object({
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
maxTokens: z.number().optional(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
metadata: z.record(z.unknown()).nullable().optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ModelProvider = z.infer<typeof modelProviderSchema>;
|
||||||
|
|
||||||
|
export const roleModelRouteSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspaceId: z.string().uuid(),
|
||||||
|
roleId: z.string().min(1),
|
||||||
|
providerAliasId: z.string().uuid(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RoleModelRoute = z.infer<typeof roleModelRouteSchema>;
|
||||||
|
|
||||||
|
export const resolvedModelSchema = z.object({
|
||||||
|
alias: z.string().min(1),
|
||||||
|
providerType: providerTypeSchema,
|
||||||
|
modelId: z.string().min(1),
|
||||||
|
baseUrl: z.string().url().optional(),
|
||||||
|
apiKey: z.string(),
|
||||||
|
extraHeaders: z.record(z.string()).optional(),
|
||||||
|
defaultParams: z
|
||||||
|
.object({
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
maxTokens: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
tier: modelTierSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResolvedModel = z.infer<typeof resolvedModelSchema>;
|
||||||
|
|
||||||
|
export const convoyModelPolicySchema = z.object({
|
||||||
|
roles: z.record(z.string()).optional(),
|
||||||
|
escalate_to: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ConvoyModelPolicy = z.infer<typeof convoyModelPolicySchema>;
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: apps-orchestrator
|
||||||
|
title: apps/orchestrator — role dispatch and gate pauses
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Long-running **`apps/orchestrator`** service: Redis job queue, convoy state machine driver, role dispatcher. Inspired by Symphony **concepts** (stall detection, token logging) — **not** Symphony codebase.
|
||||||
|
|
||||||
|
On **Go**: enqueue planning roles serially (ia → ux → architect), stop at `convoy_review`; after Gate 1, dispatch implementer jobs (parallel when `slice_dependencies` allow); fan-out audit jobs; stop at `pr_review` and post-merge gates.
|
||||||
|
|
||||||
|
**Parallel implementer fan-out:** When multiple implementer jobs target the same Mac inference host, orchestrator should pass the same `qwen-coder-mlx` alias to each job; operator must run a batching-capable MLX server (mlx-openai-server) — see `docs/adr-model-routing.md` § Local MLX. Cap concurrent implementers per host in orchestrator config if OOM occurs (agent-pipeline multitask playbook).
|
||||||
|
|
||||||
|
Each job calls `resolveModel(workspace, roleId, convoy, brief)` and passes `ResolvedModel` to worker.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- `apps/orchestrator` package in monorepo.
|
||||||
|
- Redis queue (existing infra).
|
||||||
|
- Deploy on Coolify beside `web`.
|
||||||
|
- Stall detection → `agent_runs` / convoy `blocked`.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Worker implementation (Epic 7).
|
||||||
|
- Symphony / Linear integration.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 1 (`resolveModel`).
|
||||||
|
- Epic 3 (repo link for clone path).
|
||||||
|
- Epic 4 (transitions).
|
||||||
|
- Epic 5 (gates — for dogfood; orchestrator can run in dry-run before UI).
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
- Epic 7 (worker consumes jobs).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Dry-run: orchestrator logs next role for a convoy without worker.
|
||||||
|
- [ ] Live: one implementer brief dispatched and paused correctly at gate.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed when epic graduates to `ready`._
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
Supersedes [Epic-optional-orchestrator](../../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) deferred stub with a concrete Echodo-native design.
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: apps-worker-v1
|
||||||
|
title: apps/worker v1 — minimal Echodo coding agent
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
First-party **`apps/worker`**: minimal agent runtime in the Echodo monorepo (not OpenHands). Docker sandbox per job, tool loop (`read_file`, `write_file`, `grep`, `run_command` with allowlist), git worktree per brief, MCP `claim_task` / `complete_task`, inference via `ResolvedModel` from orchestrator.
|
||||||
|
|
||||||
|
Role prompt = `.cursor/agents/role-*.md` + brief path. Model endpoint points at operator Mac (**Ollama or MLX server** — see ADR § Local MLX) or cloud APIs per registry.
|
||||||
|
|
||||||
|
**Parallel implementers:** When orchestrator dispatches N implementers to the same Mac inference host, prefer a batching-capable MLX server (**mlx-openai-server**) over `mlx_lm.server` alone. Document chosen server + port in workspace provider alias (`qwen-coder-mlx`).
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- `WorkerSession` adapter interface (for future optional OpenHands plug-in).
|
||||||
|
- `pnpm lint && pnpm test` in allowlisted commands.
|
||||||
|
- Branch + PR draft output (forge adapter from Epic 3).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Browser automation, multi-agent delegation.
|
||||||
|
- Feature parity with OpenHands.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 1, 3, 6.
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
- Epic 8 (meaningful PR webhook dogfood).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] One brief from `.convoys/` completes unattended on connected repo.
|
||||||
|
- [ ] `complete_task` includes model alias + token totals.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed when epic graduates to `ready`._
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: connected-repos
|
||||||
|
title: Connected repos — paste remote URL, forge adapters
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Workspace admins connect git remotes (Forgejo, GitHub, GitLab, Bitbucket, future Origin) via **paste URL** UX. Store `repo_links` with `forge_type`, API base, and credentials reference. Provide a `ForgeAdapter` interface for clone metadata, open PR, and (later) webhooks.
|
||||||
|
|
||||||
|
See automation foundation discussion: Vercel/Coolify-style “show me your repo.”
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- `connected_repos` (or `repo_links`) Drizzle table, workspace-scoped.
|
||||||
|
- tRPC CRUD + “test clone” (shallow fetch).
|
||||||
|
- Settings UI: connect / disconnect / list.
|
||||||
|
- Forge adapter trait + implementations: `forgejo`, `github` (minimum two).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Webhook receivers (Epic 8).
|
||||||
|
- Orchestrator clone logic (Epic 6 consumes this table).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Workspace admin auth patterns (existing).
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
- Epic 6 (orchestrator), Epic 7 (worker).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Admin pastes Forgejo URL; Echodo stores link and detects `forge_type`.
|
||||||
|
- [ ] Worker/orchestrator can read `git_remote_url` + default branch from API.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed when epic graduates to `ready`._
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: convoy-lifecycle-mcp
|
||||||
|
title: Convoy lifecycle MCP — state machine and convoy_events
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Implement agent-pipeline **Phase 2a** convoy lifecycle in Echodo MCP: `create_convoy`, `create_brief`, `transition_convoy_status`, `log_convoy_event`, `query_manifest_status`, `reconcile_from_files`.
|
||||||
|
|
||||||
|
Server-enforced state machine (draft → convoy_review → ready → in_progress → pr_review → merged → released). Human gate actors (`human-gate-1/2/3`) only for their transitions.
|
||||||
|
|
||||||
|
Reference: agent-pipeline v0.4 plan §7; local-first `.convoys/` with `.pending-mcp-sync.jsonl` outbox.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- MCP tools in `apps/mcp-server`.
|
||||||
|
- `convoy_events` table (or extend `agent_runs` pattern).
|
||||||
|
- Map convoy → Echodo `project` object; brief → `task` child (per v0.4 object mapping).
|
||||||
|
- Extended status enum documented; UI filters updated in Epic 5.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Orchestrator auto-dispatch (Epic 6).
|
||||||
|
- Deliverable templates (agent-pipeline Phase 2b).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 2 (convoy file conventions).
|
||||||
|
- Epic 3 (workspace `convoys-<repo>` convention).
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
- Epic 5, 6, 9.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Invalid transition `draft → released` rejected with structured error.
|
||||||
|
- [ ] `role-architect` cannot call `human-gate-1` transition.
|
||||||
|
- [ ] Offline convoy edit reconciles via `reconcile_from_files`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed when epic graduates to `ready`._
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: fleet-dashboard
|
||||||
|
title: Fleet dashboard — all connected repos and convoys
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Top-level **fleet** view: all `convoys-<repo>` workspaces (or connected repos) in one screen. Roll up convoys by status, gate inbox count, active `agent_runs`, recent spend by model alias.
|
||||||
|
|
||||||
|
Answers: “What needs me across all projects?”
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Route `/fleet` or home dashboard for operator account.
|
||||||
|
- Cross-workspace tRPC (membership-scoped).
|
||||||
|
- Links into per-repo mission control (Epic 5).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Multi-tenant fleet across unrelated customers (operator's own repos only for v1).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 4 (queryable convoy status).
|
||||||
|
- Epic 5 (gate semantics).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Operator with two connected repos sees combined “3 awaiting approval” badge.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed when epic graduates to `ready`._
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: forge-webhooks
|
||||||
|
title: Forge webhooks — PR and CI events drive convoy transitions
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Webhook receivers on Echodo for connected forges: PR opened → `pr_review`; PR merged → trigger doc-writer job + `merged`; CI failed → optional `blocked` or notify. Supports Forgejo and GitHub adapters minimum.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- `POST /api/webhooks/forge/:repoLinkId` with signature verification.
|
||||||
|
- Map events to `transition_convoy_status(actor: automation)`.
|
||||||
|
- Link PR branch to convoy/brief via branch naming convention or PR body marker.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Replacing Coolify deploy webhooks for Gate 3 prod promote (manual or separate integration).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 3, 4, 7.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Merge on Forgejo moves convoy from `pr_review` to `merged` when Gate 2 approved.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed when epic graduates to `ready`._
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: mission-control-gates-ui
|
||||||
|
title: Mission control — convoy kanban, gate inbox, co-pilot
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Echodo UI for human gates and convoy visibility:
|
||||||
|
|
||||||
|
- **Convoy kanban** — columns map to state machine statuses (Planning, Awaiting plan approval, Building, …).
|
||||||
|
- **Gate inbox** — “needs you” queue for `convoy_review`, `pr_review`, pre-`released`.
|
||||||
|
- **Approve / deny / request changes** — calls `transition_convoy_status` with correct `human-gate-*` actor.
|
||||||
|
- **Gate co-pilot** — AI panel using `gate-copilot` role route (`resolveModel`) to summarize plan/diff and suggest action.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Routes under `/{workspaceSlug}/convoys/` or `/mission-control/`.
|
||||||
|
- Conductor-style chat to seed convoys (can call `create_convoy` MCP or web tRPC wrapper).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Fleet cross-repo view (Epic 9).
|
||||||
|
- Unattended orchestration (Epic 6).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 4 (lifecycle transitions and queryable convoy state).
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
- Epic 6 (orchestrator pauses until gate UI exists for dogfood).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Operator sees pending Gate 1 convoy and approves → status `ready`.
|
||||||
|
- [ ] Co-pilot uses `claude-planning` alias when configured.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed when epic graduates to `ready`._
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: model-provider-registry
|
||||||
|
title: Model provider registry and per-role routing
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
agent_prompt: |
|
||||||
|
Epic: model provider registry (docs/adr-model-routing.md). Server-only change —
|
||||||
|
no UI component library inventing; reuse components/ui/. Admin-only mutations.
|
||||||
|
Filter every DB query by workspace_id. One task per session; claim_task first.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Deliver the workspace-scoped **model provider registry** and **role → alias routing** described in [docs/adr-model-routing.md](../../../docs/adr-model-routing.md): Drizzle schema, zod types in `packages/shared`, `resolveModel()`, tRPC CRUD + `testProvider`, Settings UI, and `agent_runs` model metadata on completion.
|
||||||
|
|
||||||
|
This epic is the first shipped piece of the automation-foundation plan and unblocks future orchestrator/worker work.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Tables `model_providers`, `role_model_routes`.
|
||||||
|
- Pure resolver + unit tests.
|
||||||
|
- tRPC router (workspace admin).
|
||||||
|
- Settings pages: Providers, Role routing.
|
||||||
|
- Seed/default routes for nine L2 roles + `gate-copilot` + `chat-default`.
|
||||||
|
- `complete_task` optional model logging fields.
|
||||||
|
- `.env.example` env var documentation.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Orchestrator dispatch (consumes resolver later).
|
||||||
|
- Worker inference loop.
|
||||||
|
- Parsing `model_policy` from `.convoys/*.md` files (orchestrator reads later).
|
||||||
|
- Billing/budget enforcement.
|
||||||
|
|
||||||
|
## Related tasks
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| Shared schemas and resolveModel | `./Task-shared-schemas-resolve-model.md` |
|
||||||
|
| DB schema and migration | `./Task-db-model-providers-migration.md` |
|
||||||
|
| tRPC models router | `./Task-trpc-models-router.md` |
|
||||||
|
| Settings — providers UI | `./Task-settings-ai-providers-ui.md` |
|
||||||
|
| Settings — role routing UI | `./Task-settings-role-routing-ui.md` |
|
||||||
|
| agent_runs model fields + MCP | `./Task-agent-runs-model-logging.md` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Depends on: existing auth + workspace admin patterns in `apps/web`.
|
||||||
|
- Blocks: orchestrator epic (needs `resolveModel`), worker epic (needs `ResolvedModel`).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Admin can create `claude-planning` (anthropic) and `qwen-coder` (openai_compatible + base URL) in a workspace.
|
||||||
|
- [ ] Admin can map `role-implementer` → `qwen-coder`, `role-architect` → `claude-planning`.
|
||||||
|
- [ ] `testProvider` returns success/failure without exposing API keys.
|
||||||
|
- [ ] `resolveModel()` unit tests cover override order and missing-alias error.
|
||||||
|
- [ ] At least one task in this epic completed via `claim_task` → `complete_task` with model metadata.
|
||||||
|
|
||||||
|
## Proposed timeline
|
||||||
|
|
||||||
|
| Phase | Window | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Schema + resolver | Session 1–2 | Tasks: shared schemas, migration |
|
||||||
|
| API | Session 3 | tRPC router |
|
||||||
|
| UI | Session 4–5 | Both settings pages |
|
||||||
|
| Logging | Session 6 | agent_runs + MCP |
|
||||||
|
|
||||||
|
## Pipeline / convoy mapping
|
||||||
|
|
||||||
|
When running via agent-pipeline, architect briefs should align to tasks:
|
||||||
|
|
||||||
|
| Brief # | Task slug |
|
||||||
|
|---------|-----------|
|
||||||
|
| 1 | `shared-schemas-resolve-model` |
|
||||||
|
| 2 | `db-model-providers-migration` |
|
||||||
|
| 3 | `trpc-models-router` |
|
||||||
|
| 4 | `settings-ai-providers-ui` |
|
||||||
|
| 5 | `settings-role-routing-ui` |
|
||||||
|
| 6 | `agent-runs-model-logging` |
|
||||||
|
|
||||||
|
Briefs 1–2 serial (schema depends on types); 4–5 can fan out after brief 3 if `files:` are disjoint.
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: agent-runs-model-logging
|
||||||
|
title: agent_runs model metadata and complete_task logging fields
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: model-provider-registry
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
agent_prompt: |
|
||||||
|
Read docs/adr-model-routing.md. Extend agent_runs schema + complete_task MCP +
|
||||||
|
runs UI columns. Migration via db:generate. claim_task before work;
|
||||||
|
complete_task when done — include modelAlias on your own completion as
|
||||||
|
dogfood proof.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Persist model routing metadata on `agent_runs` and accept optional model fields on `complete_task` so dogfooded pipeline sessions record which alias was used.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### DB (`agent_runs`)
|
||||||
|
|
||||||
|
Add nullable columns:
|
||||||
|
|
||||||
|
- `model_alias` varchar
|
||||||
|
- `provider_type` varchar
|
||||||
|
- `model_id` varchar
|
||||||
|
- `model_tier` varchar
|
||||||
|
|
||||||
|
Migration via `pnpm db:generate`.
|
||||||
|
|
||||||
|
### MCP `complete_task`
|
||||||
|
|
||||||
|
Extend zod input (optional):
|
||||||
|
|
||||||
|
- `modelAlias?: string`
|
||||||
|
- `modelTier?: string`
|
||||||
|
- `providerType?: string`
|
||||||
|
- `modelId?: string`
|
||||||
|
|
||||||
|
Write to `agent_runs` on close. Document in tool description.
|
||||||
|
|
||||||
|
### Runs UI
|
||||||
|
|
||||||
|
`apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx` — show model alias column in table and expanded row detail.
|
||||||
|
|
||||||
|
### `.env.example`
|
||||||
|
|
||||||
|
Add commented entries: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `NVIDIA_API_KEY`, `OLLAMA_API_KEY`, `OPENAI_BASE_URL` with note pointing to ADR.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Schema + migration
|
||||||
|
- [ ] complete_task schema + handler
|
||||||
|
- [ ] Runs UI column
|
||||||
|
- [ ] .env.example update
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
S
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Completing a run with `modelAlias: "qwen-coder"` persists and displays in runs UI
|
||||||
|
- [ ] Existing complete_task callers unchanged (fields optional)
|
||||||
|
- [ ] ADR logging contract satisfied
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-model-provider-registry.md`
|
||||||
|
- Plan: `../Plan-automation-foundation.md`
|
||||||
|
- Related: `plans/Plan-agent-coordination/`
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: db-model-providers-migration
|
||||||
|
title: Drizzle schema and migration for model_providers and role_model_routes
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: model-provider-registry
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
agent_prompt: |
|
||||||
|
Read docs/adr-model-routing.md and packages/shared model-routing types from
|
||||||
|
Task-shared-schemas-resolve-model. packages/database only. Generate migration
|
||||||
|
with pnpm db:generate — do not hand-edit SQL. claim_task before work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Add `model_providers` and `role_model_routes` tables with workspace scoping and indexes per the ADR.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Schema (`packages/database/src/schema/`)
|
||||||
|
|
||||||
|
- `model_providers.ts` — columns from ADR; unique `(workspace_id, alias)`
|
||||||
|
- `role_model_routes.ts` — FK to `model_providers`; unique `(workspace_id, role_id)`
|
||||||
|
- Re-export from `schema/index.ts`; add relations if needed
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- `pnpm db:generate` from repo root after schema edit
|
||||||
|
- Commit generated SQL under `packages/database/migrations/`
|
||||||
|
|
||||||
|
### Optional seed helper
|
||||||
|
|
||||||
|
- `seedDefaultRoleRoutes(workspaceId)` in database package or web server — inserts pipeline default role_ids pointing at aliases **only if** matching aliases exist (no auto-create of providers)
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [x] Define Drizzle tables
|
||||||
|
- [x] Generate and commit migration
|
||||||
|
- [x] Verify `workspace_id` index on both tables
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
S
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Migration applies cleanly with `pnpm db:migrate`
|
||||||
|
- [x] Every query example in epic doc filters by `workspace_id`
|
||||||
|
- [x] Types align with `packages/shared` zod schemas
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-model-provider-registry.md`
|
||||||
|
- ADR: `../../../docs/adr-model-routing.md`
|
||||||
|
- Depends on: `Task-shared-schemas-resolve-model.md`
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: settings-ai-providers-ui
|
||||||
|
title: Settings UI — AI model providers
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: model-provider-registry
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
agent_prompt: |
|
||||||
|
Read docs/adr-model-routing.md. apps/web UI only — reuse components/ui.
|
||||||
|
Route: /[workspaceSlug]/settings/ai/providers. Do not store secrets in
|
||||||
|
forms — only api_key_env_var name field. claim_task before work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Build the workspace Settings page for CRUD on model provider aliases, including a “Test connection” action.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Route
|
||||||
|
|
||||||
|
`apps/web/app/(app)/[workspaceSlug]/settings/ai/providers/page.tsx`
|
||||||
|
|
||||||
|
Add nav link under Settings (near runs/audit).
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Table: alias, display name, provider type, model id, base URL (truncated), enabled, actions
|
||||||
|
- Add/Edit dialog: all fields from ADR; provider type select drives whether base URL is required
|
||||||
|
- Test button → calls `ai.models.testProvider` → toast with latency or error
|
||||||
|
- Delete with confirm
|
||||||
|
|
||||||
|
### UX notes
|
||||||
|
|
||||||
|
- Show helper text: “API key is read from server env var `ANTHROPIC_API_KEY`” — not a password field
|
||||||
|
- Example presets (optional buttons): “Ollama local”, “MLX local (mlx-openai-server)”, “Anthropic”, “NVIDIA NIM”, “Gemini” — prefill form per `docs/adr-model-routing.md` § Provider examples
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Page + list query
|
||||||
|
- [ ] Create/edit dialog
|
||||||
|
- [ ] Test connection + delete
|
||||||
|
- [ ] Settings nav entry
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Admin can add `qwen-coder` with Mac Ollama base URL and test it
|
||||||
|
- [ ] Non-admin sees read-only or hidden mutations
|
||||||
|
- [ ] Matches existing settings page visual patterns (e.g. runs page)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-model-provider-registry.md`
|
||||||
|
- Depends on: `Task-trpc-models-router.md`
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: settings-ai-role-routing-ui
|
||||||
|
title: Settings UI — per-role model routing
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: model-provider-registry
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
agent_prompt: |
|
||||||
|
Read docs/adr-model-routing.md. apps/web UI. Route:
|
||||||
|
/[workspaceSlug]/settings/ai/role-routing. Allow adding custom role_id
|
||||||
|
strings. claim_task before work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Build the Settings page that maps `role_id` → provider alias, with pipeline defaults seeded and support for custom roles.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Route
|
||||||
|
|
||||||
|
`apps/web/app/(app)/[workspaceSlug]/settings/ai/role-routing/page.tsx`
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Table: role id, display label (optional friendly name), provider alias (dropdown from `listProviders`), tier badge from provider metadata, enabled toggle
|
||||||
|
- “Add role” — free-text `role_id` + alias dropdown (for `gate-copilot`, custom auditors, etc.)
|
||||||
|
- “Reset to pipeline defaults” — calls upsert for nine L2 roles + `gate-copilot` + `chat-default` **if** matching aliases exist; confirm dialog
|
||||||
|
- Link to ADR / note about convoy `model_policy` overrides
|
||||||
|
|
||||||
|
### Default labels
|
||||||
|
|
||||||
|
Map known `role_id` to human labels (Conductor, Architect, …) in a const — custom roles show raw id.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Page + list routes query
|
||||||
|
- [ ] Upsert/disable per row
|
||||||
|
- [ ] Add custom role flow
|
||||||
|
- [ ] Reset defaults action
|
||||||
|
- [ ] Settings nav entry
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] User can set `role-architect` → `claude-planning`, `role-implementer` → `qwen-coder`
|
||||||
|
- [ ] User can add `gate-copilot` role row
|
||||||
|
- [ ] Dropdown only lists enabled providers for the workspace
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-model-provider-registry.md`
|
||||||
|
- Depends on: `Task-trpc-models-router.md`, `Task-settings-ai-providers-ui.md` (providers should exist first for dogfood; soft dependency)
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: shared-schemas-resolve-model
|
||||||
|
title: Shared zod schemas and resolveModel() pure function
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: model-provider-registry
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
agent_prompt: |
|
||||||
|
Read docs/adr-model-routing.md first. Implement only packages/shared (and
|
||||||
|
packages/ai if needed for zero DB imports in shared). No UI, no migration.
|
||||||
|
claim_task this backlog item before coding; complete_task when done.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Add zod schemas and types for model providers, role routes, and a pure `resolveModel()` function per the ADR.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **`packages/shared`** — export:
|
||||||
|
- `providerTypeSchema` enum: `anthropic` | `openai` | `google` | `openai_compatible` | `azure_openai`
|
||||||
|
- `modelProviderSchema`, `roleModelRouteSchema`, `resolvedModelSchema`
|
||||||
|
- `resolveModel(input)` — pure function; input includes optional brief/convoy overrides, workspace routes, provider catalog
|
||||||
|
- `PIPELINE_DEFAULT_ROLE_ROUTES` constant (role_id → suggested alias names, not UUIDs)
|
||||||
|
- `LEGACY_CURSOR_MODEL_ALIASES` map for slug migration
|
||||||
|
|
||||||
|
2. **Tests** — `packages/shared/src/model-routing/*.test.ts`:
|
||||||
|
- Resolution order: brief > convoy > workspace > default
|
||||||
|
- Missing/disabled alias → structured error
|
||||||
|
- Legacy slug remapping
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
- `packages/shared` must not import DB or Node-only APIs.
|
||||||
|
- Resolver accepts plain objects (tRPC/orchestrator loads rows and passes in).
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [x] Add schemas and types
|
||||||
|
- [x] Implement `resolveModel`
|
||||||
|
- [x] Add vitest cases (≥6 scenarios)
|
||||||
|
- [x] Export from `packages/shared` index
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
S (one agent session)
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `pnpm --filter @tasks/shared test` passes with new tests
|
||||||
|
- [x] `pnpm type-check` clean
|
||||||
|
- [x] Resolver matches resolution order in ADR § Architecture
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-model-provider-registry.md`
|
||||||
|
- Plan: `../Plan-automation-foundation.md`
|
||||||
|
- ADR: `../../../docs/adr-model-routing.md`
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: trpc-models-router
|
||||||
|
title: tRPC ai.models router — CRUD, role routes, testProvider
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: model-provider-registry
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
agent_prompt: |
|
||||||
|
Read docs/adr-model-routing.md. apps/web/server only. Admin-only mutations
|
||||||
|
(match updateWorkflowPrompt bar). testProvider runs a minimal completion
|
||||||
|
server-side — never return API keys to client. claim_task before work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Add `ai.models` tRPC router: provider CRUD, role route upsert/delete, `testProvider`, and `resolveForRole` debug procedure.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Procedures
|
||||||
|
|
||||||
|
| Procedure | Auth | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `listProviders` | member | workspace-scoped |
|
||||||
|
| `createProvider` / `updateProvider` / `deleteProvider` | admin | zod validate; unique alias |
|
||||||
|
| `testProvider` | admin | one short completion; return `{ ok, latencyMs, error? }` |
|
||||||
|
| `listRoleRoutes` | member | join provider alias for display |
|
||||||
|
| `upsertRoleRoute` / `deleteRoleRoute` | admin | custom `roleId` allowed |
|
||||||
|
| `resolveForRole` | member | calls shared `resolveModel` with DB rows |
|
||||||
|
|
||||||
|
### Model client factory
|
||||||
|
|
||||||
|
- Add `packages/ai/src/providers/resolve-client.ts` (or similar): given `ResolvedModel`, return Vercel AI SDK model instance for `generateText` in `testProvider`
|
||||||
|
- Support at least: `anthropic`, `openai`, `google`, `openai_compatible`
|
||||||
|
|
||||||
|
### Wire router
|
||||||
|
|
||||||
|
- Register in `apps/web/server/root.ts`
|
||||||
|
- Export input schemas from shared where possible
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Implement provider CRUD
|
||||||
|
- [ ] Implement role route CRUD
|
||||||
|
- [ ] Implement testProvider with provider factory
|
||||||
|
- [ ] Implement resolveForRole
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Admin can create provider via tRPC; non-admin gets FORBIDDEN
|
||||||
|
- [ ] testProvider works against env-configured Anthropic or Ollama when aliases exist
|
||||||
|
- [ ] `pnpm lint && pnpm type-check` clean
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-model-provider-registry.md`
|
||||||
|
- Depends on: `Task-db-model-providers-migration.md`, `Task-shared-schemas-resolve-model.md`
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: pipeline-convoy-scaffolding
|
||||||
|
title: Pipeline convoy scaffolding — .convoys/ and metrics in tasks repo
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
status: draft
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-07-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Install L3 convoy infrastructure in the `tasks` repo so agent-pipeline dogfooding works: `.convoys/README.md`, `scripts/log-convoy-event.sh`, `.cursor/rules/convoy-planning.mdc`, `.gitignore` entry for `.convoys/.metrics.jsonl`.
|
||||||
|
|
||||||
|
Can run **in parallel** with Epic 1 (model provider registry).
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Copy/adapt from `agent-pipeline` L3 `_common` templates (manifest-aware paths).
|
||||||
|
- Document kickoff ritual in `.convoys/README.md`.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Convoy lifecycle MCP (Epic 4).
|
||||||
|
- Creating convoy files for other consumer repos.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- None (bootstrap from agent-pipeline templates).
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
- Epic 4 (convoy lifecycle MCP) — operators need a place to write convoy files first.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `bash scripts/log-convoy-event.sh role=role-conductor convoy=test` appends valid JSONL.
|
||||||
|
- [ ] `role-conductor` can write `.convoys/<slug>.md` without conflicting rules.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_To be decomposed by architect when epic graduates to `ready`._
|
||||||
228
plans/Plan-automation-foundation/Plan-automation-foundation.md
Normal file
228
plans/Plan-automation-foundation/Plan-automation-foundation.md
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
---
|
||||||
|
kind: plan
|
||||||
|
slug: automation-foundation
|
||||||
|
title: Automation foundation — mission control, model routing, orchestrator path
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_plan_id: null
|
||||||
|
updated_at: "2026-07-07"
|
||||||
|
agent_prompt: |
|
||||||
|
You are implementing Echodo's automation foundation. Read docs/adr-model-routing.md
|
||||||
|
before touching model routing code. This repo uses pnpm, Drizzle, tRPC, zod in
|
||||||
|
packages/shared, and multitenancy via workspace_id on every tenant table.
|
||||||
|
|
||||||
|
Dogfooding (required for every task in this plan):
|
||||||
|
1. At session start: call MCP claim_task with this task's backlogItemId (import
|
||||||
|
plans/ if the row is missing).
|
||||||
|
2. At session end: call MCP complete_task with outcome, tokensTotal, and
|
||||||
|
modelAlias/modelTier when you used a routed model.
|
||||||
|
3. Stay within the task's acceptance criteria; do not widen scope.
|
||||||
|
4. Run pnpm lint && pnpm type-check && pnpm test before declaring done.
|
||||||
|
|
||||||
|
Stack: Next.js App Router, packages/database for schema, apps/web for UI.
|
||||||
|
Never store API keys in committed files or DB — only api_key_env_var names.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan overview
|
||||||
|
|
||||||
|
Echodo is evolving from a multitenant task app with MCP coordination into **mission control** for an agent pipeline: fleet view, human gates, per-role model routing, and (later) a built-in orchestrator and worker. This plan delivers the **first concrete slice** — the model provider registry and role routing system defined in [docs/adr-model-routing.md](../../docs/adr-model-routing.md).
|
||||||
|
|
||||||
|
Later epics in this plan (2–9) add convoy scaffolding, connected repos, lifecycle MCP, gates UI, orchestrator, worker, webhooks, and fleet dashboard. Epic 1 gives them a stable `resolveModel()` contract.
|
||||||
|
|
||||||
|
## Dogfooding the agent pipeline
|
||||||
|
|
||||||
|
**Yes — this plan is designed to dogfood the agent-pipeline end to end.**
|
||||||
|
|
||||||
|
| Pipeline artifact | How we use it |
|
||||||
|
|-------------------|---------------|
|
||||||
|
| L2 roles | Implementation runs as `role-implementer` per task brief (or ad hoc with implementer rules) |
|
||||||
|
| `claim_task` / `complete_task` | Every task session claims and completes via Echodo MCP |
|
||||||
|
| `agent_prompt` (plan → epic → task) | First-message context for agents; inheritance from this frontmatter |
|
||||||
|
| `docs/adr-model-routing.md` | Architect contract — agents read before coding |
|
||||||
|
| `.convoys/model-routing-registry.md` | **Created at kickoff** via `role-conductor` — tracks IA/UX/architecture for the initiative (not stored under `plans/`) |
|
||||||
|
| `model_policy` in convoy | Dogfoods alias-based routing (Claude planning, Qwen local) once registry exists |
|
||||||
|
| `scripts/log-convoy-event.sh` | Log each role completion to `.convoys/.metrics.jsonl` when L3 convoy scaffolding is present |
|
||||||
|
|
||||||
|
**Kickoff ritual (human + conductor):**
|
||||||
|
|
||||||
|
1. Prompt: *"Start a new convoy: Implement workspace-scoped model provider registry and role routing per docs/adr-model-routing.md. Success = operators can configure Anthropic + Ollama aliases and assign them to pipeline roles from Settings."*
|
||||||
|
2. Conductor writes `.convoys/model-routing-registry.md` with `classification: server-only` (skip ia, ux, design, a11y).
|
||||||
|
3. Architect decomposes into briefs that **map 1:1 to tasks in `Epic-model-provider-registry`** (below).
|
||||||
|
4. Each implementer session: `claim_task` on the matching `Task-*.md` → implement → `complete_task`.
|
||||||
|
|
||||||
|
## Getting started (operator checklist)
|
||||||
|
|
||||||
|
Do these **before or in parallel with** Epic 1 implementation. Nothing here blocks writing code; some items unblock dogfooding and `testProvider` against real endpoints.
|
||||||
|
|
||||||
|
### A. Repo and backlog (one time)
|
||||||
|
|
||||||
|
- [ ] `pnpm install` at repo root
|
||||||
|
- [ ] `.env` from `.env.example` — `DATABASE_URL`, `REDIS_URL`, `NEXTAUTH_*` at minimum
|
||||||
|
- [ ] `pnpm db:migrate` if schema is behind
|
||||||
|
- [ ] `pnpm --filter @tasks/database import:markdown-backlog` — loads `plans/Plan-automation-foundation/**` into DB for `claim_task`
|
||||||
|
- [ ] Confirm Echodo MCP in `.cursor/mcp.json` works (restart Cursor after MCP changes)
|
||||||
|
|
||||||
|
### B. Local inference on Mac (pick one or both)
|
||||||
|
|
||||||
|
See ADR § **Local MLX on Apple Silicon** and **Local Ollama**.
|
||||||
|
|
||||||
|
| Goal | Action |
|
||||||
|
|------|--------|
|
||||||
|
| Simplest local coder | Install [Ollama](https://ollama.com), `ollama pull qwen2.5-coder:32b`, listen on LAN if Coolify must reach Mac |
|
||||||
|
| Best Apple Silicon perf / MLX models | `pip install mlx-openai-server` (preferred if you expect **parallel implementers**), launch with `--host 0.0.0.0` |
|
||||||
|
| Quick MLX smoke test | `pip install mlx-lm` → `mlx_lm.server --model mlx-community/...` on port 8080 |
|
||||||
|
|
||||||
|
Note your Mac LAN IP and port for registry aliases later (`http://<ip>:8000/v1` or `:11434/v1`).
|
||||||
|
|
||||||
|
### C. Cloud / free-tier aliases (optional until Epic 1 UI exists)
|
||||||
|
|
||||||
|
Configure env vars on the machine that runs `apps/web` / worker (Coolify or local):
|
||||||
|
|
||||||
|
- `ANTHROPIC_API_KEY` — planning roles (or use NIM/Gemini free tier per ADR references)
|
||||||
|
- `GOOGLE_GENERATIVE_AI_API_KEY`, `NVIDIA_API_KEY` — optional free-tier planning/gates
|
||||||
|
|
||||||
|
Until Settings UI ships, `OPENAI_BASE_URL` + `OPENAI_API_KEY` still power the existing chat route.
|
||||||
|
|
||||||
|
### D. Agent-pipeline convoy (when you start building)
|
||||||
|
|
||||||
|
- [ ] Run **role-conductor** (Cursor): convoy for model registry per kickoff ritual above → `.convoys/model-routing-registry.md`
|
||||||
|
- [ ] **role-architect** → briefs map to Epic 1 tasks
|
||||||
|
- [ ] Epic 2 (optional parallel): install `.convoys/` scaffolding + `log-convoy-event.sh`
|
||||||
|
|
||||||
|
### E. First implementation task
|
||||||
|
|
||||||
|
Start with **`Task-shared-schemas-resolve-model`** — no DB migration, pure `packages/shared` + tests. Then migration, tRPC, UI.
|
||||||
|
|
||||||
|
### F. Not needed yet
|
||||||
|
|
||||||
|
- Forgejo / connected repos (Epic 3)
|
||||||
|
- Orchestrator or worker (Epics 6–7)
|
||||||
|
- LiteLLM (optional gateway later)
|
||||||
|
- Symphony
|
||||||
|
|
||||||
|
## Objectives and success criteria
|
||||||
|
|
||||||
|
- Workspace admins can CRUD model provider aliases (Anthropic, OpenAI, Google, OpenAI-compatible for Ollama/NIM/LiteLLM).
|
||||||
|
- Workspace admins can map any `role_id` (nine L2 defaults + custom) to a provider alias.
|
||||||
|
- `resolveModel()` implements ADR resolution order (brief → convoy → workspace → template).
|
||||||
|
- Settings UI: Providers + Role routing pages with connection test.
|
||||||
|
- `agent_runs` records `model_alias`, `provider_type`, `model_id`, `model_tier` on completion.
|
||||||
|
- Documented in ADR; `.env.example` updated with referenced env var names.
|
||||||
|
|
||||||
|
## Scope and boundaries
|
||||||
|
|
||||||
|
**In scope (full plan)**
|
||||||
|
|
||||||
|
- Nine epics — see [Epic roadmap](#epic-roadmap-complete) below.
|
||||||
|
- Epic 1 (model registry) is **ready** with six tasks; epics 2–9 are **draft** stubs for architect decomposition.
|
||||||
|
- ADR at `docs/adr-model-routing.md`.
|
||||||
|
|
||||||
|
**Out of scope (separate future plans)**
|
||||||
|
|
||||||
|
- Agent-pipeline Phase 2b deliverable templates.
|
||||||
|
- Agent-pipeline Phase 4 drift/rollup workers (replace GHA cron).
|
||||||
|
- LiteLLM deployment guide (operators use OpenAI-compatible alias).
|
||||||
|
- Symphony adoption.
|
||||||
|
|
||||||
|
## Cross-team collaborators
|
||||||
|
|
||||||
|
| Name / group | Role |
|
||||||
|
|--------------|------|
|
||||||
|
| Solo operator | Product, implement, dogfood pipeline |
|
||||||
|
|
||||||
|
## Epic roadmap (complete)
|
||||||
|
|
||||||
|
Sequential order with parallel opportunities noted. Only **Epic 1** has bead-scale tasks today; epics 2–9 are draft stubs ready for architect decomposition when the prior epic lands.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
E1[1 Model registry]
|
||||||
|
E2[2 Convoy scaffolding]
|
||||||
|
E3[3 Connected repos]
|
||||||
|
E4[4 Convoy lifecycle MCP]
|
||||||
|
E5[5 Gates UI]
|
||||||
|
E6[6 Orchestrator]
|
||||||
|
E7[7 Worker v1]
|
||||||
|
E8[8 Forge webhooks]
|
||||||
|
E9[9 Fleet dashboard]
|
||||||
|
|
||||||
|
E1 --> E6
|
||||||
|
E1 --> E7
|
||||||
|
E2 --> E4
|
||||||
|
E3 --> E6
|
||||||
|
E4 --> E5
|
||||||
|
E4 --> E6
|
||||||
|
E5 --> E6
|
||||||
|
E6 --> E7
|
||||||
|
E7 --> E8
|
||||||
|
E5 --> E9
|
||||||
|
E4 --> E9
|
||||||
|
```
|
||||||
|
|
||||||
|
| # | Epic | Status | Delivers | Depends on |
|
||||||
|
|---|------|--------|----------|------------|
|
||||||
|
| 1 | [Model provider registry](./Epic-model-provider-registry/Epic-model-provider-registry.md) | **ready** (6 tasks) | `resolveModel()`, Settings AI pages, run model logging | — |
|
||||||
|
| 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | draft | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) |
|
||||||
|
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | draft | `repo_links` table, “paste remote URL”, forge adapter interface | — |
|
||||||
|
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | draft | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 |
|
||||||
|
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | draft | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 |
|
||||||
|
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
|
||||||
|
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 |
|
||||||
|
| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | PR opened/merged/CI webhooks → convoy transitions | 3, 4, 7 |
|
||||||
|
| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | draft | Cross-repo home, `convoys-*` workspaces, “needs you” rollup | 4, 5 |
|
||||||
|
|
||||||
|
### Overlap with existing plans
|
||||||
|
|
||||||
|
| Existing artifact | How this roadmap relates |
|
||||||
|
|-------------------|-------------------------|
|
||||||
|
| [Plan-agent-coordination](../Plan-agent-coordination/Plan-agent-coordination.md) | Epics 1 (logging) and 4–7 **extend** it; `claim_task` / `complete_task` stay canonical |
|
||||||
|
| [Epic-optional-orchestrator](../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) | **Superseded in intent** by Epic 6 here — richer spec, Echodo-native, no Symphony dependency |
|
||||||
|
| Agent-pipeline v0.4 Phase 2a | Epic 4 implements the MCP tools described there |
|
||||||
|
| Agent-pipeline v0.4 Phase 4 workers | Out of scope until Epic 8+; drift/rollup is a future Plan, not this roadmap |
|
||||||
|
|
||||||
|
### End-state (what “done” looks like for the full plan)
|
||||||
|
|
||||||
|
Operator opens Echodo fleet view → starts convoy chat → hits **Go** → orchestrator runs pipeline roles with per-role models → worker executes briefs against connected repos → gates surface in inbox with AI co-pilot → approve → repeat until `released`.
|
||||||
|
|
||||||
|
## Epics (links)
|
||||||
|
|
||||||
|
| Epic | Link |
|
||||||
|
|------|------|
|
||||||
|
| 1 Model provider registry | `./Epic-model-provider-registry/Epic-model-provider-registry.md` |
|
||||||
|
| 2 Pipeline convoy scaffolding | `./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md` |
|
||||||
|
| 3 Connected repos | `./Epic-connected-repos/Epic-connected-repos.md` |
|
||||||
|
| 4 Convoy lifecycle MCP | `./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md` |
|
||||||
|
| 5 Mission control gates UI | `./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md` |
|
||||||
|
| 6 Orchestrator | `./Epic-apps-orchestrator/Epic-apps-orchestrator.md` |
|
||||||
|
| 7 Worker v1 | `./Epic-apps-worker-v1/Epic-apps-worker-v1.md` |
|
||||||
|
| 8 Forge webhooks | `./Epic-forge-webhooks/Epic-forge-webhooks.md` |
|
||||||
|
| 9 Fleet dashboard | `./Epic-fleet-dashboard/Epic-fleet-dashboard.md` |
|
||||||
|
|
||||||
|
## Metrics and milestones
|
||||||
|
|
||||||
|
| Milestone | Epic | Metric |
|
||||||
|
|-----------|------|--------|
|
||||||
|
| M0 ADR | — | `docs/adr-model-routing.md` merged |
|
||||||
|
| M1 Model registry | 1 | Settings: configure + test Anthropic + Ollama aliases |
|
||||||
|
| M2 Dogfood loop | 1–2 | One task via claim_task → complete_task with model fields |
|
||||||
|
| M3 Repos connected | 3 | Paste Forge/GitHub URL; clone succeeds from Echodo |
|
||||||
|
| M4 Convoy state | 4 | `transition_convoy_status` enforces gate actors |
|
||||||
|
| M5 Human surface | 5 | Gate inbox shows pending `convoy_review` |
|
||||||
|
| M6 Automation | 6–7 | Orchestrator dispatches one implementer brief unattended |
|
||||||
|
| M7 PR loop | 8 | Merge webhook moves convoy to `merged` |
|
||||||
|
| M8 Fleet | 9 | Single view across ≥2 connected repos |
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Mac Ollama unreachable from Coolify-hosted web | `testProvider` surfaces error; document LAN URL; dev can run web locally |
|
||||||
|
| Cursor slug drift in old convoys | Legacy alias map in ADR; conductor uses registry aliases for new convoys |
|
||||||
|
| Scope creep into orchestrator | ADR + plan explicitly defer daemon/worker |
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [ADR: Model routing](../../docs/adr-model-routing.md)
|
||||||
|
- [Plan: Agent coordination](../Plan-agent-coordination/Plan-agent-coordination.md)
|
||||||
|
- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md`
|
||||||
|
|
@ -335,6 +335,9 @@ importers:
|
||||||
'@ai-sdk/anthropic':
|
'@ai-sdk/anthropic':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.2.12(zod@3.25.76)
|
version: 1.2.12(zod@3.25.76)
|
||||||
|
'@ai-sdk/google':
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.2.22(zod@3.25.76)
|
||||||
'@ai-sdk/openai':
|
'@ai-sdk/openai':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.3.24(zod@3.25.76)
|
version: 1.3.24(zod@3.25.76)
|
||||||
|
|
@ -354,6 +357,9 @@ importers:
|
||||||
|
|
||||||
packages/database:
|
packages/database:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@tasks/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.0
|
specifier: ^0.38.0
|
||||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
||||||
|
|
@ -401,6 +407,12 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.0.0
|
zod: ^3.0.0
|
||||||
|
|
||||||
|
'@ai-sdk/google@1.2.22':
|
||||||
|
resolution: {integrity: sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.0.0
|
||||||
|
|
||||||
'@ai-sdk/openai@1.3.24':
|
'@ai-sdk/openai@1.3.24':
|
||||||
resolution: {integrity: sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==}
|
resolution: {integrity: sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -5391,6 +5403,12 @@ snapshots:
|
||||||
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
|
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
|
'@ai-sdk/google@1.2.22(zod@3.25.76)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider': 1.1.3
|
||||||
|
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
'@ai-sdk/openai@1.3.24(zod@3.25.76)':
|
'@ai-sdk/openai@1.3.24(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 1.1.3
|
'@ai-sdk/provider': 1.1.3
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue