@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.
@@ -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 { getModelOptions } from "@/lib/model-catalog";
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={getModelOptions(draft.agentType)}
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
+ }
@@ -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
+ }
@@ -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
  }
@@ -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
- : resolveModelForAgent(
257
- agentType,
258
- input.model?.trim() || project?.default_model
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.691",
3
+ "version": "0.3.692",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"