diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index bf6a878c..7018caf9 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -104,6 +104,7 @@ class ModelEntry: ), "kimi:kimi-k2.6": ModelEntry("Kimi K2.6 · Moonshot", _AGENTIC, 256_000), "minimax:MiniMax-M2.5": ModelEntry("MiniMax M2.5 · MiniMax"), + "nvidia:z-ai/glm-5.2": ModelEntry("GLM-5.2 · NVIDIA NIM"), "qwen:qwen3-max": ModelEntry("Qwen3 Max · Alibaba", _AGENTIC, 256_000), "xai:grok-4.3": ModelEntry("Grok 4.3 · xAI", _AGENTIC, 256_000), "mistral:mistral-large-latest": ModelEntry( diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 6c147705..9f0a247a 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -188,6 +188,17 @@ def _build_ollama(profile: dict[str, Any], secrets: Any) -> ProviderClient: return OpenAIProvider(api_key="ollama", base_url=base_url) +def _build_nvidia(profile: dict[str, Any], secrets: Any) -> ProviderClient: + # NVIDIA NIM is OpenAI-compatible, but it uses its own key namespace and endpoint. + api_key = ((profile or {}).get("api_key") or "").strip() or ( + os.environ.get("NVIDIA_API_KEY", "").strip() + ) + if not api_key: + raise RuntimeError("No NVIDIA API key configured — add it in Settings ▸ Models.") + base_url = ((profile or {}).get("base_url") or "").strip() or "https://integrate.api.nvidia.com/v1" + return OpenAIProvider(api_key=api_key, base_url=base_url) + + def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = None): """Builder factory for vendors reached through their OpenAI-compatible API (Z AI, DeepSeek, Kimi, MiniMax, Qwen, xAI, Mistral). The key is resolved from the vendor's OWN profile (or its @@ -517,6 +528,31 @@ def _compat( recommended_model="mistral-large-latest", env_key="MISTRAL_API_KEY", ), + ProviderDescriptor( + name="nvidia", + title="NVIDIA NIM", + needs_key=True, + fields=[ + ProviderField( + "api_key", + "NVIDIA API key", + secret=True, + placeholder="nvapi-…", + ), + ProviderField( + "base_url", + "Endpoint", + required=False, + default="https://integrate.api.nvidia.com/v1", + placeholder="https://integrate.api.nvidia.com/v1", + help="Prefilled with NVIDIA's OpenAI-compatible NIM endpoint.", + ), + ], + build=_build_nvidia, + recommended_model="z-ai/glm-5.2", + env_key="NVIDIA_API_KEY", + blurb="Uses NVIDIA NIM's OpenAI-compatible API — the endpoint is prefilled, just add your key.", + ), _compat( "meta", "Meta (Muse Spark)", diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index e6dfa409..ed865ed5 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -45,6 +45,7 @@ const SETTINGS = { model_labels: { "anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic", "zai:glm-5.2": "GLM-5.2 · Z AI", + "nvidia:z-ai/glm-5.2": "GLM-5.2 · NVIDIA NIM", }, // Context windows (subset — mirrors /v1/settings.model_context_windows); drives the // composer usage chip's context-fill meter. @@ -336,6 +337,8 @@ const PROVIDERS = [ { name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null }, // zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb. { name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null }, + // nvidia: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb. + { name: "nvidia", title: "NVIDIA NIM", needs_key: true, blurb: "Uses NVIDIA NIM's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "NVIDIA API key", secret: true, required: true, help: "", placeholder: "nvapi-…" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with NVIDIA's OpenAI-compatible NIM endpoint.", placeholder: "https://integrate.api.nvidia.com/v1", default: "https://integrate.api.nvidia.com/v1" }], configured: false, values: {}, suggested_models: ["z-ai/glm-5.2"], key_set_at: null, last_used_at: null }, // ollama: keyless local provider — "configured" without proving anything runs; the // onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39). { name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null }, diff --git a/surfaces/gui/package-lock.json b/surfaces/gui/package-lock.json index 56baf1e1..8e9c992a 100644 --- a/surfaces/gui/package-lock.json +++ b/surfaces/gui/package-lock.json @@ -98,6 +98,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -447,6 +448,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -470,6 +472,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1799,6 +1802,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1952,6 +1956,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1963,6 +1968,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2316,6 +2322,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3392,6 +3399,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3408,6 +3416,7 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4713,6 +4722,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -4917,6 +4927,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4929,6 +4940,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5481,6 +5493,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5777,6 +5790,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index d18825cf..15d31096 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -224,6 +224,7 @@ export function App() { // composer's "No model connected" chip. Default true so we don't flash the chip before settings // load; corrected by loadSettings. const [modelReady, setModelReady] = useState(true); + const [modelsLoaded, setModelsLoaded] = useState(false); const [surface, setSurface] = useState< "session" | "scheduled" | "integrations" | "audit" | "inbox" | "persona" | "settings" >("session"); @@ -513,9 +514,12 @@ export function App() { setModelContextWindows(s.model_context_windows || {}); setContextBar(s.context_bar === true); setModelReady(s.model_ready); + setModelsLoaded(true); if (s.surfaces) setSurfaces(s.surfaces); }) - .catch(() => {}); + .catch(() => { + setModelsLoaded(true); + }); // Open Settings → Configure Models (from the composer's "No model connected" chip). const openModelSetup = () => openSettings("models"); @@ -1593,6 +1597,7 @@ export function App() { mode={mode} model={model} models={models} + modelsLoaded={modelsLoaded} modelLabels={modelLabels} running={running} connected={connected} diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 532c437d..13f1cdb3 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -48,6 +48,7 @@ interface Props { mode: string; model: string; models?: string[]; + modelsLoaded?: boolean; modelLabels?: Record; // curated display names (raw id when absent) // The model is FIXED once the session has history (§17): the picker renders ONLY on a fresh // session; after the first turn the fact lives in the topbar subtitle (§22) — no @@ -410,7 +411,7 @@ export function Composer(props: Props) { } }; - const modelsLoaded = !!(props.models && props.models.length); + const modelsLoaded = props.modelsLoaded ?? !!(props.models && props.models.length); const modelOptions: Option[] = Array.from( new Set([props.model, ...(props.models || [])]), ).map((m) => ({ diff --git a/surfaces/gui/src/components/Composer.voice.test.tsx b/surfaces/gui/src/components/Composer.voice.test.tsx index ff13ea0e..cef1bdfa 100644 --- a/surfaces/gui/src/components/Composer.voice.test.tsx +++ b/surfaces/gui/src/components/Composer.voice.test.tsx @@ -101,4 +101,11 @@ describe("Composer voice input (§37)", () => { expect((await screen.findByRole("alert")).textContent).toContain("No microphone is available."); expect(screen.getByLabelText("Start dictation").hasAttribute("disabled")).toBe(false); }); + + it("an empty loaded model list shows the dropdown instead of the loading chip", () => { + render(); + + expect(screen.queryByTestId("models-loading")).toBeNull(); + expect(screen.getByRole("button", { name: /gpt-5.6-sol/i })).toBeTruthy(); + }); }); diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts index 093c4fdd..016bdc9d 100644 --- a/surfaces/gui/src/providers/logos.ts +++ b/surfaces/gui/src/providers/logos.ts @@ -21,6 +21,7 @@ import mistral from "./logos/mistral.svg"; import qwen from "./logos/qwen.svg"; import minimax from "./logos/minimax.svg"; import xai from "./logos/xai.svg"; +import nvidia from "./logos/nvidia.svg"; import meta from "./logos/meta.svg"; export const PROVIDER_LOGOS: Record = { @@ -41,6 +42,7 @@ export const PROVIDER_LOGOS: Record = { qwen, minimax, xai, + nvidia, }; export const PROVIDER_ORDER = [ @@ -55,6 +57,7 @@ export const PROVIDER_ORDER = [ "fireworks", "together", "zai", + "nvidia", "kimi", "deepseek", "mistral", diff --git a/surfaces/gui/src/providers/logos/nvidia.svg b/surfaces/gui/src/providers/logos/nvidia.svg new file mode 100644 index 00000000..6a96ec07 --- /dev/null +++ b/surfaces/gui/src/providers/logos/nvidia.svg @@ -0,0 +1,5 @@ + + + + +