mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
53 lines
2.0 KiB
TypeScript
53 lines
2.0 KiB
TypeScript
import { queryOptions } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import type { RuntimeModelsResult } from "../types/agent";
|
|
|
|
export const runtimeModelsKeys = {
|
|
all: () => ["runtimes", "models"] as const,
|
|
forRuntime: (runtimeId: string) =>
|
|
[...runtimeModelsKeys.all(), runtimeId] as const,
|
|
};
|
|
|
|
const POLL_INTERVAL_MS = 500;
|
|
const POLL_TIMEOUT_MS = 30_000;
|
|
|
|
// resolveRuntimeModels initiates a list-models request against the daemon
|
|
// (via heartbeat piggyback) and polls until the daemon reports back or
|
|
// the request times out. Returns both the models list and a
|
|
// `supported` flag: `supported=false` means the provider ignores
|
|
// per-agent model selection entirely (hermes today) — the UI uses
|
|
// this to disable its dropdown instead of accepting a value that
|
|
// wouldn't be honoured at runtime.
|
|
export async function resolveRuntimeModels(
|
|
runtimeId: string,
|
|
): Promise<RuntimeModelsResult> {
|
|
const initial = await api.initiateListModels(runtimeId);
|
|
const start = Date.now();
|
|
let current = initial;
|
|
while (current.status === "pending" || current.status === "running") {
|
|
if (Date.now() - start > POLL_TIMEOUT_MS) {
|
|
throw new Error("model discovery timed out");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
current = await api.getListModelsResult(runtimeId, initial.id);
|
|
}
|
|
if (current.status === "failed" || current.status === "timeout") {
|
|
throw new Error(current.error || "model discovery failed");
|
|
}
|
|
return { models: current.models ?? [], supported: current.supported };
|
|
}
|
|
|
|
export function runtimeModelsOptions(runtimeId: string | null | undefined) {
|
|
return queryOptions({
|
|
queryKey: runtimeId
|
|
? runtimeModelsKeys.forRuntime(runtimeId)
|
|
: runtimeModelsKeys.all(),
|
|
queryFn: () => resolveRuntimeModels(runtimeId as string),
|
|
enabled: Boolean(runtimeId),
|
|
// Models rarely change; cache for 60s to match the server-side
|
|
// cache in agent.ListModels.
|
|
staleTime: 60_000,
|
|
retry: false,
|
|
});
|
|
}
|