diff --git a/apps/app/public/ext-lmstudio.svg b/apps/app/public/ext-lmstudio.svg
new file mode 100644
index 000000000..924d3c025
--- /dev/null
+++ b/apps/app/public/ext-lmstudio.svg
@@ -0,0 +1 @@
+
diff --git a/apps/app/src/app/extensions.ts b/apps/app/src/app/extensions.ts
index f4eb2a2f1..ae87c15b5 100644
--- a/apps/app/src/app/extensions.ts
+++ b/apps/app/src/app/extensions.ts
@@ -353,4 +353,30 @@ export const BUILT_IN_OPENWORK_EXTENSION_MANIFESTS: OpenWorkExtensionManifest[]
],
lifecycle: { reload: ["config"], detection: ["provider:ollama"] },
},
+ {
+ schemaVersion: 1,
+ id: "lmstudio",
+ name: "LM Studio",
+ description: "Local model provider at http://127.0.0.1:1234.",
+ source: { format: "openwork-builtin", origin: "builtin", trusted: true },
+ icon: { src: "/ext-lmstudio.svg" },
+ composer: { prompt: "Use the LM Studio extension to " },
+ setup: {
+ instructions:
+ "Start LM Studio's local server (Developer tab), choose a downloaded model, then add it as an OpenCode provider.",
+ primaryCta: "Add LM Studio model",
+ },
+ resources: [
+ { type: "local-service", id: "lmstudio-api", label: "LM Studio API", description: "http://127.0.0.1:1234", required: true },
+ { type: "provider", id: "lmstudio", providerId: "lmstudio", packageName: "@ai-sdk/openai-compatible", required: true },
+ ],
+ contributions: [
+ { type: "settings-panel", ref: "openwork.lmstudio.settings", location: "settings-detail" },
+ { type: "composer-prompt", prompt: "Use the LM Studio extension to ", location: "composer" },
+ ],
+ enablement: [
+ { type: "provider-connected", ref: "lmstudio", label: "LM Studio provider" },
+ ],
+ lifecycle: { reload: ["config"], detection: ["provider:lmstudio"] },
+ },
];
diff --git a/apps/app/src/react-app/domains/settings/lmstudio-config.tsx b/apps/app/src/react-app/domains/settings/lmstudio-config.tsx
new file mode 100644
index 000000000..9dca1e929
--- /dev/null
+++ b/apps/app/src/react-app/domains/settings/lmstudio-config.tsx
@@ -0,0 +1,347 @@
+/** @jsxImportSource react */
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { CheckCircle2, Download, Loader2, RefreshCw, XCircle } from "lucide-react";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@/components/ui/empty";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+ FieldTitle,
+} from "@/components/ui/field";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { LMSTUDIO_PROVIDER_CONFIG } from "./openai-image-extension";
+import { registerExtensionConfig, type ExtensionConfigContext } from "./extension-registry";
+
+const lmstudioConfigFactory = (ctx: ExtensionConfigContext) => (
+
+);
+
+registerExtensionConfig("openwork.lmstudio.settings", lmstudioConfigFactory);
+registerExtensionConfig("lmstudio", lmstudioConfigFactory);
+
+/** A model entry surfaced by the LM Studio local server. */
+type LMStudioModel = {
+ id: string;
+ /** "llm" | "vlm" | "embeddings" from the native /api/v0/models endpoint. */
+ type?: string;
+ state?: string;
+ maxContextLength?: number;
+};
+
+type LMStudioStatus = "checking" | "running" | "unreachable";
+
+/** Base URL for the LM Studio server without the trailing OpenAI "/v1" segment. */
+const LMSTUDIO_BASE = LMSTUDIO_PROVIDER_CONFIG.baseURL.replace(/\/v1\/?$/, "");
+
+/**
+ * Fetch the models currently available in the running LM Studio instance.
+ *
+ * Prefers the native REST endpoint (`/api/v0/models`) which reports the model
+ * type and load state, and falls back to the OpenAI-compatible `/v1/models`
+ * endpoint on older LM Studio builds. The previous behaviour relied on the
+ * static models.dev catalog, which only ever listed three hardcoded models
+ * regardless of what the user had downloaded (#bug: hardcoded LM Studio models).
+ */
+async function fetchLMStudioModels(): Promise<{ status: "running" | "unreachable"; models: LMStudioModel[] }> {
+ // Native endpoint first — richer metadata (type, loaded state, context length).
+ try {
+ const response = await fetch(`${LMSTUDIO_BASE}/api/v0/models`, {
+ signal: AbortSignal.timeout(3000),
+ });
+ if (response.ok) {
+ const data = await response.json();
+ const models = (Array.isArray(data?.data) ? data.data : [])
+ .map((entry: Record): LMStudioModel => ({
+ id: String(entry.id ?? ""),
+ type: typeof entry.type === "string" ? entry.type : undefined,
+ state: typeof entry.state === "string" ? entry.state : undefined,
+ maxContextLength:
+ typeof entry.max_context_length === "number" ? entry.max_context_length : undefined,
+ }))
+ .filter((model: LMStudioModel) => model.id && model.type !== "embeddings");
+ return { status: "running", models };
+ }
+ } catch {
+ // Fall through to the OpenAI-compatible endpoint below.
+ }
+
+ // OpenAI-compatible fallback.
+ try {
+ const response = await fetch(`${LMSTUDIO_PROVIDER_CONFIG.baseURL}/models`, {
+ signal: AbortSignal.timeout(3000),
+ });
+ if (!response.ok) {
+ return { status: "unreachable", models: [] };
+ }
+ const data = await response.json();
+ const models = (Array.isArray(data?.data) ? data.data : [])
+ .map((entry: Record): LMStudioModel => ({ id: String(entry.id ?? "") }))
+ .filter((model: LMStudioModel) => model.id);
+ return { status: "running", models };
+ } catch {
+ return { status: "unreachable", models: [] };
+ }
+}
+
+function useLMStudioModels() {
+ const { data, isFetching, refetch } = useQuery({
+ queryKey: ["lmstudio", "models"],
+ queryFn: fetchLMStudioModels,
+ refetchOnWindowFocus: false,
+ });
+
+ const status: LMStudioStatus = isFetching ? "checking" : (data?.status ?? "unreachable");
+
+ return { data, isFetching, refetch, status };
+}
+
+export type LMStudioConfigProps = {
+ busy: boolean;
+ status: string | null;
+ error: string | null;
+ onInstall: (input: {
+ providerId: string;
+ name: string;
+ baseURL: string;
+ modelId: string;
+ modelName: string;
+ setDefault: boolean;
+ }) => void | Promise;
+};
+
+export function LMStudioConfig(props: LMStudioConfigProps) {
+ const [selectedModel, setSelectedModel] = useState("");
+ const [setDefault, setSetDefault] = useState(true);
+
+ const { data, isFetching, refetch, status } = useLMStudioModels();
+
+ const models = data?.models ?? [];
+
+ useEffect(() => {
+ if (!selectedModel && models[0]) {
+ setSelectedModel(models[0].id);
+ }
+ }, [models, selectedModel]);
+
+ const handleInstall = () => {
+ if (!selectedModel) {
+ return;
+ }
+
+ void props.onInstall({
+ providerId: LMSTUDIO_PROVIDER_CONFIG.providerId,
+ name: LMSTUDIO_PROVIDER_CONFIG.name,
+ baseURL: LMSTUDIO_PROVIDER_CONFIG.baseURL,
+ modelId: selectedModel,
+ modelName: selectedModel,
+ setDefault,
+ });
+ };
+
+ if (status === "unreachable") {
+ return (
+
+
+ Configuration
+ Connect to a local LM Studio instance and choose a model.
+
+
+
+
+
+ {props.error ? (
+
+
+ {props.error}
+
+ ) : null}
+
+
+
+
+
+
+ LM Studio isn't running
+
+ Start LM Studio and enable its local server (Developer tab) so OpenWork can list your
+ downloaded models from {LMSTUDIO_BASE}.
+
+
+
+
+ }
+ >
+ Download LM Studio
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Configuration
+ Connect to a local LM Studio instance and choose a model.
+
+
+
+
+
+ {props.error ? (
+
+
+ {props.error}
+
+ ) : null}
+
+
+ {status === "checking" ? (
+
+ ) : status === "running" ? (
+
+ ) : (
+
+ )}
+
+ {status === "checking"
+ ? "Checking LM Studio..."
+ : status === "running"
+ ? `LM Studio running (${models.length} model${models.length === 1 ? "" : "s"})`
+ : "LM Studio not reachable"}
+
+
+
+ {/* Model selection */}
+ {status === "running" && models.length > 0 ? (
+
+ ) : null}
+
+ {/* No models */}
+ {status === "running" && models.length === 0 ? (
+
+
+
+
+
+ No models available
+
+ Download a model in LM Studio, then refresh to add it to your workspace.
+
+
+
+ ) : null}
+
+ {props.status ? (
+
+
+ {props.status}
+
+ ) : null}
+
+
+
+
+ }
+ />
+ Use as default model in workspace
+
+
+
+
+
+ );
+}
+
+interface ModelListProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ children: React.ReactNode;
+}
+
+export function ModelList({ value, onValueChange, children }: ModelListProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface ModelListItemProps {
+ model: LMStudioModel;
+}
+
+function ModelListItem({ model }: ModelListItemProps) {
+ const detail = model.maxContextLength
+ ? `${Math.round(model.maxContextLength / 1024)}K ctx`
+ : model.type;
+ return (
+
+
+
+
+ {model.id}
+ {detail ? {detail} : null}
+
+
+
+ );
+}
diff --git a/apps/app/src/react-app/domains/settings/openai-image-extension.ts b/apps/app/src/react-app/domains/settings/openai-image-extension.ts
index 6e9cf3e5b..6b3861c1f 100644
--- a/apps/app/src/react-app/domains/settings/openai-image-extension.ts
+++ b/apps/app/src/react-app/domains/settings/openai-image-extension.ts
@@ -14,5 +14,11 @@ export const OLLAMA_PROVIDER_CONFIG = {
defaultModelId: "qwen2.5-coder:7b",
};
+export const LMSTUDIO_PROVIDER_CONFIG = {
+ providerId: "lmstudio",
+ name: "LM Studio (local)",
+ baseURL: "http://127.0.0.1:1234/v1",
+};
+
export const OPENAI_IMAGE_EXTENSION_ID = "openai-image-generation";
export const OPENAI_IMAGE_MODEL = "gpt-image-2";
diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx
index c8ffc5df7..debf4227e 100644
--- a/apps/app/src/react-app/shell/settings-route.tsx
+++ b/apps/app/src/react-app/shell/settings-route.tsx
@@ -58,6 +58,7 @@ import { AiSettingsView } from "@/react-app/domains/settings/pages/ai-view";
// Side-effect imports: register extension config components into the registry.
import "@/react-app/domains/settings/openai-image-gen-config";
import "@/react-app/domains/settings/ollama-config";
+import "@/react-app/domains/settings/lmstudio-config";
import "@/react-app/domains/settings/computer-use-config";
import "@/react-app/domains/settings/browser-extension-config";
import "@/react-app/domains/settings/openwork-voice-config";