feat(automation): complete model provider registry epic (tasks 3–6)
Adds ai.models tRPC router with provider CRUD, role routes, testProvider, and resolveForRole; Vercel AI SDK client factory in packages/ai; Settings pages for providers and role routing; agent_runs model metadata columns with MCP complete_task fields and runs UI column. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f04122fd04
commit
dfe73b3647
22 changed files with 4785 additions and 6 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { modelTierSchema, providerTypeSchema } from "@tasks/shared/model-routing";
|
||||
import { db } from "../db.js";
|
||||
import { eq } from "../drizzle.js";
|
||||
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
|
||||
|
|
@ -44,6 +45,15 @@ const completeTaskInputSchema = z.object({
|
|||
.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.",
|
||||
),
|
||||
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>;
|
||||
|
|
@ -115,6 +125,10 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
|||
const incomingMeta: Record<string, unknown> = {};
|
||||
if (input.client) incomingMeta.client = input.client;
|
||||
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 mergedMeta = hasIncomingMeta
|
||||
? { ...(run.metadata ?? {}), ...incomingMeta }
|
||||
|
|
@ -131,6 +145,14 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
|||
tokensTotal,
|
||||
notes: input.notes ?? 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 } : {}),
|
||||
})
|
||||
.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>
|
||||
);
|
||||
}
|
||||
|
|
@ -105,6 +105,19 @@ function describeOutcome(
|
|||
* null and ignore values that aren't strings (defensive against
|
||||
* 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): {
|
||||
client: 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">Duration</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-right font-medium">Tokens</th>
|
||||
</tr>
|
||||
|
|
@ -229,6 +243,7 @@ export default function RunsPage() {
|
|||
{runsQuery.data.rows.map((row) => {
|
||||
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
|
||||
const agent = describeAgent(row.metadata);
|
||||
const modelLabel = describeModel(row);
|
||||
const isOpen = expanded === row.id;
|
||||
const hasDetail = Boolean(row.notes || row.error);
|
||||
return (
|
||||
|
|
@ -289,6 +304,9 @@ export default function RunsPage() {
|
|||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="max-w-[16ch] truncate px-4 py-2 text-xs text-muted-foreground">
|
||||
{modelLabel ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -306,7 +324,7 @@ export default function RunsPage() {
|
|||
{isOpen && hasDetail ? (
|
||||
<tr>
|
||||
<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 ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
User,
|
||||
Users,
|
||||
Zap,
|
||||
|
|
@ -144,6 +145,24 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
|
|||
<ScrollText className="size-4 text-muted-foreground" />
|
||||
Audit log
|
||||
</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
|
||||
className="gap-2.5 px-4"
|
||||
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 { objects } from "@tasks/database/schema";
|
||||
import { router, workspaceProcedure } from "@/server/trpc";
|
||||
import { aiModelsRouter } from "@/server/routers/ai-models";
|
||||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
|
|
@ -185,4 +186,6 @@ export const aiRouter = router({
|
|||
}
|
||||
return { actions: suggestionsForContext({ ...input, objectType }) };
|
||||
}),
|
||||
|
||||
models: aiModelsRouter,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ export const runsRouter = router({
|
|||
tokensTotal: agentRuns.tokensTotal,
|
||||
notes: agentRuns.notes,
|
||||
metadata: agentRuns.metadata,
|
||||
modelAlias: agentRuns.modelAlias,
|
||||
providerType: agentRuns.providerType,
|
||||
modelId: agentRuns.modelId,
|
||||
modelTier: agentRuns.modelTier,
|
||||
})
|
||||
.from(agentRuns)
|
||||
.leftJoin(
|
||||
|
|
@ -147,6 +151,10 @@ export const runsRouter = router({
|
|||
tokensTotal: agentRuns.tokensTotal,
|
||||
notes: agentRuns.notes,
|
||||
metadata: agentRuns.metadata,
|
||||
modelAlias: agentRuns.modelAlias,
|
||||
providerType: agentRuns.providerType,
|
||||
modelId: agentRuns.modelId,
|
||||
modelTier: agentRuns.modelTier,
|
||||
})
|
||||
.from(agentRuns)
|
||||
.leftJoin(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"ai": "^4.0.0",
|
||||
"@ai-sdk/openai": "^1.0.0",
|
||||
"@ai-sdk/anthropic": "^1.0.0",
|
||||
"@ai-sdk/google": "^1.0.0",
|
||||
"@tasks/shared": "workspace:*",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ export {
|
|||
type OpenAIClientResult,
|
||||
type OpenAIModelId,
|
||||
} from "./providers/openai";
|
||||
export {
|
||||
createLanguageModelFromResolved,
|
||||
type CreateLanguageModelResult,
|
||||
} from "./providers/resolve-client";
|
||||
export {
|
||||
EDITOR_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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
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
|
|
@ -78,6 +78,13 @@
|
|||
"when": 1783450794221,
|
||||
"tag": "0010_legal_stryfe",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1783451144455,
|
||||
"tag": "0011_cool_captain_america",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -67,6 +67,10 @@ export const agentRuns = pgTable(
|
|||
tokensTotal: integer("tokens_total"),
|
||||
notes: text("notes"),
|
||||
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) => ({
|
||||
workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ kind: epic
|
|||
slug: model-provider-registry
|
||||
title: Model provider registry and per-role routing
|
||||
plan_slug: automation-foundation
|
||||
status: ready
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
cursor_epic_id: null
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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: ready
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ slug: settings-ai-providers-ui
|
|||
title: Settings UI — AI model providers
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: model-provider-registry
|
||||
status: ready
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ slug: settings-ai-role-routing-ui
|
|||
title: Settings UI — per-role model routing
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: model-provider-registry
|
||||
status: ready
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ slug: trpc-models-router
|
|||
title: tRPC ai.models router — CRUD, role routes, testProvider
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: model-provider-registry
|
||||
status: ready
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
|
|
|
|||
|
|
@ -335,6 +335,9 @@ importers:
|
|||
'@ai-sdk/anthropic':
|
||||
specifier: ^1.0.0
|
||||
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':
|
||||
specifier: ^1.0.0
|
||||
version: 1.3.24(zod@3.25.76)
|
||||
|
|
@ -404,6 +407,12 @@ packages:
|
|||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -5394,6 +5403,12 @@ snapshots:
|
|||
'@ai-sdk/provider-utils': 2.2.8(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)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.1.3
|
||||
|
|
|
|||
Loading…
Reference in a new issue