@saadnvd1/agent-os 0.3.691 → 0.3.692
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/discovered-models/route.ts +34 -0
- package/components/Draft/DraftPanel.tsx +10 -2
- package/components/Draft/draftModels.test.ts +31 -0
- package/components/Draft/draftModels.ts +17 -0
- package/data/agents/index.ts +37 -0
- package/lib/chat/discovered-models.ts +87 -0
- package/lib/model-catalog.ts +9 -0
- package/lib/sessions/launch.ts +12 -5
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { discoveredModels } from "@/lib/chat/discovered-models";
|
|
4
|
+
import { getProject } from "@/lib/projects";
|
|
5
|
+
|
|
6
|
+
const query = z.object({
|
|
7
|
+
agent: z.string().min(1),
|
|
8
|
+
projectId: z.string().optional(),
|
|
9
|
+
refresh: z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// GET /api/discovered-models?agent=&projectId=&refresh=1 - the models an agent
|
|
13
|
+
// reports for a project's folder, for a draft that has no session yet. Empty
|
|
14
|
+
// for an agent with a fixed list.
|
|
15
|
+
export async function GET(request: NextRequest) {
|
|
16
|
+
const parsed = query.safeParse(
|
|
17
|
+
Object.fromEntries(request.nextUrl.searchParams)
|
|
18
|
+
);
|
|
19
|
+
if (!parsed.success)
|
|
20
|
+
return NextResponse.json({ error: "agent is required" }, { status: 400 });
|
|
21
|
+
const { agent, projectId, refresh } = parsed.data;
|
|
22
|
+
const folder = projectId ? getProject(projectId)?.working_directory : null;
|
|
23
|
+
try {
|
|
24
|
+
return NextResponse.json({
|
|
25
|
+
models: await discoveredModels(agent, folder, refresh !== undefined),
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: `Couldn't list the models: ${message}` },
|
|
31
|
+
{ status: 502 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -8,7 +8,8 @@ import { Button } from "@/components/ui/button";
|
|
|
8
8
|
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
9
9
|
import type { Project } from "@/lib/db";
|
|
10
10
|
import { draftComposerKey, type Draft } from "@/lib/drafts";
|
|
11
|
-
import {
|
|
11
|
+
import { discoversModels } from "@/lib/model-catalog";
|
|
12
|
+
import { useDiscoveredModels } from "@/data/agents";
|
|
12
13
|
import { useHostsQuery } from "@/data/hosts";
|
|
13
14
|
import { useGitCheck } from "@/data/git/queries";
|
|
14
15
|
import { useLaunchSession } from "@/data/sessions";
|
|
@@ -18,6 +19,7 @@ import { setPendingPrompt } from "@/stores/initialPrompt";
|
|
|
18
19
|
import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
|
|
19
20
|
import { DraftChips } from "./DraftChips";
|
|
20
21
|
import { DraftHeading } from "./DraftHeading";
|
|
22
|
+
import { draftModels } from "./draftModels";
|
|
21
23
|
|
|
22
24
|
// A new session before its first send: choices on top, a composer below.
|
|
23
25
|
// Sending makes the session (and its worktree) and turns this tab into it.
|
|
@@ -41,6 +43,12 @@ export function DraftPanel({
|
|
|
41
43
|
const { data: git } = useGitCheck(
|
|
42
44
|
project && draft?.hostId === "local" ? project.working_directory : ""
|
|
43
45
|
);
|
|
46
|
+
const discovers = !!draft && discoversModels(draft.agentType);
|
|
47
|
+
const found = useDiscoveredModels(
|
|
48
|
+
draft?.agentType ?? "",
|
|
49
|
+
draft?.projectId ?? null,
|
|
50
|
+
discovers
|
|
51
|
+
);
|
|
44
52
|
const launch = useLaunchSession();
|
|
45
53
|
const createTask = useCreateTask();
|
|
46
54
|
const [prefill, setPrefill] = useState<{ text: string; at: number }>();
|
|
@@ -148,7 +156,7 @@ export function DraftPanel({
|
|
|
148
156
|
}
|
|
149
157
|
onSend={(text, images, files) => void send(text, images, files)}
|
|
150
158
|
onStop={() => {}}
|
|
151
|
-
models={
|
|
159
|
+
models={draftModels(draft.agentType, found)}
|
|
152
160
|
model={draft.model}
|
|
153
161
|
onSetModel={(model) => draftsActions.update(draft.id, { model })}
|
|
154
162
|
access={draft.openPr ? undefined : draft.access}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { draftModels } from "./draftModels";
|
|
3
|
+
|
|
4
|
+
const qwen = { value: "ollama/qwen3", label: "Qwen 3" };
|
|
5
|
+
|
|
6
|
+
describe("draftModels", () => {
|
|
7
|
+
it("lists the default and then what OpenCode reports", () => {
|
|
8
|
+
const list = draftModels("opencode", {
|
|
9
|
+
data: [qwen],
|
|
10
|
+
isPending: false,
|
|
11
|
+
isError: false,
|
|
12
|
+
});
|
|
13
|
+
expect(list.map((m) => m.value)).toEqual(["default", "ollama/qwen3"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("says so beside the default while loading and on failure", () => {
|
|
17
|
+
const loading = draftModels("opencode", {
|
|
18
|
+
isPending: true,
|
|
19
|
+
isError: false,
|
|
20
|
+
});
|
|
21
|
+
const failed = draftModels("opencode", { isPending: false, isError: true });
|
|
22
|
+
expect(loading).toHaveLength(1);
|
|
23
|
+
expect(loading[0].label).toContain("loading");
|
|
24
|
+
expect(failed[0].label).toContain("couldn't");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps a fixed list as it is", () => {
|
|
28
|
+
const list = draftModels("claude", { isPending: true, isError: false });
|
|
29
|
+
expect(list.map((m) => m.value)).toContain("opus");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
2
|
+
import type { Draft } from "@/lib/drafts";
|
|
3
|
+
import { discoversModels, getModelOptions } from "@/lib/model-catalog";
|
|
4
|
+
|
|
5
|
+
// A fixed list, or the agent's default followed by what it reports; while it
|
|
6
|
+
// loads or if it fails, the default alone says so.
|
|
7
|
+
export function draftModels(
|
|
8
|
+
agentType: Draft["agentType"],
|
|
9
|
+
found: { data?: ChatModel[]; isPending: boolean; isError: boolean }
|
|
10
|
+
): ChatModel[] {
|
|
11
|
+
const options = getModelOptions(agentType);
|
|
12
|
+
if (!discoversModels(agentType)) return options;
|
|
13
|
+
if (found.data) return [...options, ...found.data];
|
|
14
|
+
const [first, ...rest] = options;
|
|
15
|
+
const note = found.isError ? "couldn't list models" : "loading models…";
|
|
16
|
+
return [{ ...first, label: `${first.label} (${note})` }, ...rest];
|
|
17
|
+
}
|
package/data/agents/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { AgentProbe } from "@/lib/agents/probe";
|
|
3
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
3
4
|
|
|
4
5
|
export const agentKeys = {
|
|
5
6
|
all: ["agents"] as const,
|
|
@@ -35,3 +36,39 @@ export function useRecheckAgents() {
|
|
|
35
36
|
onSuccess: (agents) => client.setQueryData(agentKeys.status(), agents),
|
|
36
37
|
});
|
|
37
38
|
}
|
|
39
|
+
|
|
40
|
+
export const discoveredModelKeys = {
|
|
41
|
+
list: (agent: string, projectId: string | null) =>
|
|
42
|
+
[...agentKeys.all, "models", agent, projectId] as const,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// What an agent without a fixed list reports it can run, for a draft's
|
|
46
|
+
// picker. Off for agents with a fixed list.
|
|
47
|
+
export function useDiscoveredModels(
|
|
48
|
+
agent: string,
|
|
49
|
+
projectId: string | null,
|
|
50
|
+
enabled: boolean
|
|
51
|
+
) {
|
|
52
|
+
const client = useQueryClient();
|
|
53
|
+
const key = discoveredModelKeys.list(agent, projectId);
|
|
54
|
+
const fetchModels = async (refresh: boolean) => {
|
|
55
|
+
const params = new URLSearchParams({ agent });
|
|
56
|
+
if (projectId) params.set("projectId", projectId);
|
|
57
|
+
if (refresh) params.set("refresh", "1");
|
|
58
|
+
const res = await fetch(`/api/discovered-models?${params}`);
|
|
59
|
+
const data = await res.json().catch(() => ({}));
|
|
60
|
+
if (!res.ok) throw new Error(data.error || "Couldn't list the models");
|
|
61
|
+
return data.models as ChatModel[];
|
|
62
|
+
};
|
|
63
|
+
const query = useQuery({
|
|
64
|
+
queryKey: key,
|
|
65
|
+
queryFn: () => fetchModels(false),
|
|
66
|
+
enabled,
|
|
67
|
+
staleTime: 5 * 60_000,
|
|
68
|
+
retry: false,
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
...query,
|
|
72
|
+
refresh: async () => client.setQueryData(key, await fetchModels(true)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import { discoversModels } from "../model-catalog";
|
|
3
|
+
import { chatDriverFor } from "./drivers";
|
|
4
|
+
import type { ChatModel } from "./events";
|
|
5
|
+
|
|
6
|
+
const TTL_MS = 5 * 60_000;
|
|
7
|
+
const TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
interface Entry {
|
|
10
|
+
models: ChatModel[];
|
|
11
|
+
at: number;
|
|
12
|
+
}
|
|
13
|
+
const cache = new Map<string, Entry>();
|
|
14
|
+
const loading = new Map<string, Promise<ChatModel[]>>();
|
|
15
|
+
|
|
16
|
+
const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
|
|
17
|
+
|
|
18
|
+
async function discover(agentType: string, cwd: string): Promise<ChatModel[]> {
|
|
19
|
+
const driver = chatDriverFor(agentType);
|
|
20
|
+
if (!driver) return [];
|
|
21
|
+
const ac = new AbortController();
|
|
22
|
+
const timer = setTimeout(
|
|
23
|
+
() => ac.abort(new Error("Discovery timed out")),
|
|
24
|
+
TIMEOUT_MS
|
|
25
|
+
);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
try {
|
|
28
|
+
const found = await Promise.race([
|
|
29
|
+
driver.discover({ cwd, env: {}, signal: ac.signal }),
|
|
30
|
+
new Promise<never>((_, reject) =>
|
|
31
|
+
ac.signal.addEventListener("abort", () => reject(ac.signal.reason), {
|
|
32
|
+
once: true,
|
|
33
|
+
})
|
|
34
|
+
),
|
|
35
|
+
]);
|
|
36
|
+
return found.models;
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The models an agent reports for a folder, cached for a few minutes.
|
|
43
|
+
// `refresh` skips the cache; a failure throws and is never cached.
|
|
44
|
+
export async function discoveredModels(
|
|
45
|
+
agentType: string,
|
|
46
|
+
folder: string | null | undefined,
|
|
47
|
+
refresh = false
|
|
48
|
+
): Promise<ChatModel[]> {
|
|
49
|
+
if (!discoversModels(agentType)) return [];
|
|
50
|
+
const cwd = expand(folder?.trim() || "~");
|
|
51
|
+
const key = `${agentType}:${cwd}`;
|
|
52
|
+
const hit = cache.get(key);
|
|
53
|
+
if (!refresh && hit && Date.now() - hit.at < TTL_MS) return hit.models;
|
|
54
|
+
const running = loading.get(key);
|
|
55
|
+
if (running && !refresh) return running;
|
|
56
|
+
const run = discover(agentType, cwd)
|
|
57
|
+
.then((models) => {
|
|
58
|
+
cache.set(key, { models, at: Date.now() });
|
|
59
|
+
return models;
|
|
60
|
+
})
|
|
61
|
+
.finally(() => {
|
|
62
|
+
if (loading.get(key) === run) loading.delete(key);
|
|
63
|
+
});
|
|
64
|
+
loading.set(key, run);
|
|
65
|
+
return run;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clearDiscoveredModels(): void {
|
|
69
|
+
cache.clear();
|
|
70
|
+
loading.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Whether the agent reports this model. A failed discovery says no, so a
|
|
74
|
+
// launch falls back to the agent's default rather than failing.
|
|
75
|
+
export async function isDiscoveredModel(
|
|
76
|
+
agentType: string,
|
|
77
|
+
folder: string | null | undefined,
|
|
78
|
+
model: string
|
|
79
|
+
): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
return (await discoveredModels(agentType, folder)).some(
|
|
82
|
+
(m) => m.value === model
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/model-catalog.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentType } from "./providers";
|
|
2
2
|
import { AGENT_DEFAULT_MODEL } from "./providers/registry";
|
|
3
|
+
import { supportsChat } from "./chat/capabilities";
|
|
3
4
|
|
|
4
5
|
export interface ModelOption {
|
|
5
6
|
value: string;
|
|
@@ -48,6 +49,14 @@ const DEFAULT_MODEL_BY_AGENT: Partial<Record<AgentType, string>> = {
|
|
|
48
49
|
gemini: "gemini-3.8-flash",
|
|
49
50
|
};
|
|
50
51
|
|
|
52
|
+
export function hasStaticModelCatalog(agentType: string): boolean {
|
|
53
|
+
return agentType in MODEL_OPTIONS_BY_AGENT;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A chat agent with no fixed list: it reports what it can run itself.
|
|
57
|
+
export const discoversModels = (agentType: string): boolean =>
|
|
58
|
+
supportsChat(agentType) && !hasStaticModelCatalog(agentType);
|
|
59
|
+
|
|
51
60
|
export function getModelOptions(agentType: AgentType): ModelOption[] {
|
|
52
61
|
return MODEL_OPTIONS_BY_AGENT[agentType] ?? AGENT_DEFAULT_OPTIONS;
|
|
53
62
|
}
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { randomUUID } from "crypto";
|
|
|
11
11
|
import { db, queries, type Session } from "../db";
|
|
12
12
|
import type { AgentType } from "../providers";
|
|
13
13
|
import type { ChatAccess, ChatFile, ChatImage } from "../chat/events";
|
|
14
|
-
import { resolveModelForAgent } from "../model-catalog";
|
|
14
|
+
import { discoversModels, resolveModelForAgent } from "../model-catalog";
|
|
15
|
+
import { isDiscoveredModel } from "../chat/discovered-models";
|
|
15
16
|
import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
|
|
16
17
|
import { ANTHROPIC } from "../models/roles";
|
|
17
18
|
import { getProject } from "../projects";
|
|
@@ -251,12 +252,18 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
251
252
|
routed && (routed.provider.id === ANTHROPIC || view === "chat")
|
|
252
253
|
? routed
|
|
253
254
|
: null;
|
|
255
|
+
const wanted = input.model?.trim() || project?.default_model;
|
|
256
|
+
// An agent without a static list runs what it reports for this folder.
|
|
257
|
+
const discovered =
|
|
258
|
+
!interactive &&
|
|
259
|
+
wanted &&
|
|
260
|
+
discoversModels(agentType) &&
|
|
261
|
+
(await isDiscoveredModel(agentType, projectPath, wanted));
|
|
254
262
|
const model = interactive
|
|
255
263
|
? interactive.model
|
|
256
|
-
:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
);
|
|
264
|
+
: discovered && wanted
|
|
265
|
+
? wanted
|
|
266
|
+
: resolveModelForAgent(agentType, wanted);
|
|
260
267
|
const text = input.prompt?.trim() ?? "";
|
|
261
268
|
const naming: Naming = text
|
|
262
269
|
? await nameFor(text, projectPath, input.name)
|