From a93b45fb05f37d884b95da70d1fea5eacae14045 Mon Sep 17 00:00:00 2001 From: Moaaz Tarek Date: Sun, 16 Aug 2026 12:00:34 +0300 Subject: [PATCH 1/3] Add Z.ai provider with API-key authentication and reasoning support --- .../references/providers.md | 1 + .../changes/add-zai-provider/.openspec.yaml | 2 + openspec/changes/add-zai-provider/design.md | 87 +++++++ openspec/changes/add-zai-provider/proposal.md | 33 +++ .../specs/netclaw-model-providers/spec.md | 87 +++++++ openspec/changes/add-zai-provider/tasks.md | 23 ++ .../Tui/ProviderManagerViewModelTests.cs | 10 +- .../ModelIdNormalizer.cs | 1 + .../OpenAiCompatibleEndpointTests.cs | 60 +++++ .../Providers/ZaiProviderTests.cs | 240 ++++++++++++++++++ .../Configuration/ProviderPluginFactory.cs | 4 + src/Netclaw.Providers/ILlmProviderPlugin.cs | 6 + .../LlmProviderServiceExtensions.cs | 3 + .../ProviderDescriptorCatalog.cs | 5 + .../ProviderDescriptorServiceExtensions.cs | 3 + .../SelfHosted/OpenAiCompatibleChatClient.cs | 22 +- .../SelfHosted/OpenAiCompatibleEndpoint.cs | 19 +- src/Netclaw.Providers/Zai/ZaiDescriptor.cs | 79 ++++++ .../Zai/ZaiProviderPlugin.cs | 43 ++++ tests/smoke/tapes/init-wizard.tape | 2 +- tests/smoke/tapes/provider-add.tape | 2 +- .../tapes/screenshots/wizard-screens.tape | 2 +- 22 files changed, 723 insertions(+), 11 deletions(-) create mode 100644 openspec/changes/add-zai-provider/.openspec.yaml create mode 100644 openspec/changes/add-zai-provider/design.md create mode 100644 openspec/changes/add-zai-provider/proposal.md create mode 100644 openspec/changes/add-zai-provider/specs/netclaw-model-providers/spec.md create mode 100644 openspec/changes/add-zai-provider/tasks.md create mode 100644 src/Netclaw.Daemon.Tests/Providers/OpenAiCompatibleEndpointTests.cs create mode 100644 src/Netclaw.Daemon.Tests/Providers/ZaiProviderTests.cs create mode 100644 src/Netclaw.Providers/Zai/ZaiDescriptor.cs create mode 100644 src/Netclaw.Providers/Zai/ZaiProviderPlugin.cs diff --git a/feeds/skills/.system/files/netclaw-operations/references/providers.md b/feeds/skills/.system/files/netclaw-operations/references/providers.md index 3db64f903..571bbcb54 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/providers.md +++ b/feeds/skills/.system/files/netclaw-operations/references/providers.md @@ -28,6 +28,7 @@ and a `type` (well-known identifier). Manage them with `netclaw provider`: | `github-copilot` | OAuth device flow only | Requires active Copilot subscription on the GitHub account | | `veniceai` | API key | OpenAI-compatible at `https://api.venice.ai/api/v1`. Suppresses Venice's prepended system prompt by default; opt in via `VendorOptions.IncludeVeniceSystemPrompt = true` | | `deepseek` | API key | DeepSeek hosted API at `https://api.deepseek.com/v1`. Current model ids: `deepseek-v4-flash` and `deepseek-v4-pro` | +| `zai` | API key | Z.ai GLM Coding Plan at `https://api.z.ai/api/coding/paas/v4`. Current models: `glm-5.3`, `glm-5-turbo`, `glm-4.7`; requests for `glm-5.2`/`glm-5.1` are server-routed to `glm-5.3`. For the pay-as-you-go platform, set `--endpoint https://api.z.ai/api/paas/v4` | Provider-specific behavior toggles belong under `Providers..VendorOptions`. Netclaw keeps that bag opaque at the core diff --git a/openspec/changes/add-zai-provider/.openspec.yaml b/openspec/changes/add-zai-provider/.openspec.yaml new file mode 100644 index 000000000..d7bc0110d --- /dev/null +++ b/openspec/changes/add-zai-provider/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/add-zai-provider/design.md b/openspec/changes/add-zai-provider/design.md new file mode 100644 index 000000000..e98cb97c7 --- /dev/null +++ b/openspec/changes/add-zai-provider/design.md @@ -0,0 +1,87 @@ +## Context + +Netclaw routes model access through provider descriptors, provider plugins, and `Microsoft.Extensions.AI.IChatClient`. The existing OpenAI-compatible transport supports streaming, tools, reasoning content, usage, and provider errors. + +Z.ai uses an OpenAI-compatible API with required Bearer authentication. Its thinking mode defines a provider-specific request field and tool-loop replay rules. The response streams `reasoning_content`, the same field DeepSeek uses. + +Session actors remain transport-agnostic. This change affects no actor message, persistence record, or session identity. + +## Goals / Non-Goals + +**Goals:** + +- Add a first-class `zai` provider with API-key authentication. +- Reuse the existing first-party transport without changing generic provider payloads. +- Support Z.ai reasoning controls and tool-call history. +- Give operators clear setup, probe, and failure information. + +**Non-Goals:** + +- Add a third-party Z.ai SDK. +- Add OAuth or account and billing operations. +- Require a live Z.ai account in CI. + +## Decisions + +### Reuse the first-party OpenAI-compatible transport + +The Z.ai plugin will construct `OpenAiCompatibleChatClient`. A required wire profile will select generic or Z.ai behavior. + +This choice preserves Netclaw's media, stream, tool, usage, error, and telemetry behavior. A third-party SDK would duplicate that behavior and add supply-chain risk. + +### Isolate Z.ai wire behavior + +The Z.ai profile will omit local-server fields such as `return_progress`. It will serialize assistant `TextReasoningContent` as `reasoning_content` during tool-loop replay. + +The generic profile will retain its current payload. This boundary prevents a Z.ai rule from changing llama.cpp, vLLM, or DwarfStar requests. + +### Use MEAI reasoning options + +Z.ai exposes a binary thinking toggle only. The transport will map `ReasoningEffort.None` to disabled thinking. Any other effort maps to enabled thinking. + +Z.ai has no `reasoning_effort` gradation field. The transport will not emit one. + +The existing Netclaw reasoning-suppression intent will map to Z.ai's disabled thinking field. Provider types will not leak into session actors. + +### Use current documented model metadata + +The `/models` response omits capability metadata, and context windows are documented per model id, not per family prefix. The descriptor will enrich an exact-match table: `glm-5.3` gets a one-million-token window; `glm-5.2` gets a 200,000-token window; both get text modalities. All other model IDs will retain unknown metadata. + +On the coding plan, `glm-5.1`/`glm-5.2` requests are server-routed to `glm-5.3`. The `glm-5.2` enrichment therefore understates live capacity, never overstates it. + +The model editor will fail visibly when it cannot resolve an unknown context window. It will not invent a fallback value. + +### Default to the GLM Coding Plan endpoint + +The descriptor will default to `https://api.z.ai/api/coding/paas/v4`, the endpoint for the GLM Coding Plan subscription most operators hold. Pay-as-you-go operators will override `Endpoint` with the platform base `https://api.z.ai/api/paas/v4`. + +The live coding-plan `/models` endpoint returns the model list with Bearer authentication, so discovery needs no curated fallback. + +### Accept any trailing version segment in endpoint resolution + +`OpenAiCompatibleEndpoint.FromBaseUrl` will treat any trailing `v` path segment as an already-versioned base. The previous check matched only `/v1` and `/api/v1`, so a `v4` base produced a `/v4/v1/chat/completions` request that failed with 404. + +Version-like words without digits, such as `vpreview`, will not match. Bare hosts keep the `/v1` default. + +### Keep authentication fail-closed + +The descriptor will expose only `ApiKeyAuth`. The probe and chat client will send the stored key as an HTTP Bearer token. + +A missing key will fail before persistence or runtime use. An invalid key will produce the existing actionable provider error. + +## Risks / Trade-offs + +- Z.ai can change model IDs or context limits. The live catalog finds IDs, and explicit metadata applies only to known IDs. +- Z.ai can change its reasoning contract. Focused wire tests will detect payload drift. +- Generic transport edits can cause regressions. A required profile and generic payload tests isolate the change. +- A fake server cannot prove live vendor behavior. An optional live smoke test will provide final confidence. + +## Migration Plan + +Existing configurations require no migration. The new provider type becomes available after upgrade. + +Rollback removes `zai` profiles from runtime support. Existing provider entries remain operator-owned configuration and secrets. + +## Open Questions + +None. diff --git a/openspec/changes/add-zai-provider/proposal.md b/openspec/changes/add-zai-provider/proposal.md new file mode 100644 index 000000000..9c5d3a8ac --- /dev/null +++ b/openspec/changes/add-zai-provider/proposal.md @@ -0,0 +1,33 @@ +## Why + +Netclaw cannot configure Z.ai's hosted API (GLM models) as a first-class provider with required API-key authentication. Generic configuration also lacks Z.ai-specific reasoning and tool-loop behavior. + +## What Changes + +- Add `zai` as a first-class provider type. +- Require a Z.ai API key and store it through the existing secrets path. +- Use Z.ai's stable OpenAI-compatible chat and model endpoints. +- Map MEAI reasoning options to Z.ai's wire fields. +- Preserve `reasoning_content` across tool-call turns. +- Add provider discovery, diagnostics, CLI, TUI, and operator guidance. +- Keep required tests independent of live Z.ai credentials. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `netclaw-model-providers`: Add the Z.ai provider contract, authentication, discovery, reasoning, and tool-loop requirements. + +## Impact + +The change affects provider descriptors, provider plugins, the shared OpenAI-compatible transport, CLI and TUI provider catalogs, tests, and operator guidance. + +The change adds no third-party SDK dependency. Session actors continue to use `Microsoft.Extensions.AI.IChatClient`. + +The provider stores the API key only in the encrypted secrets path. Missing or invalid credentials fail visibly. + +The MVP excludes OAuth, automatic account creation, billing management, and mandatory live-provider tests. diff --git a/openspec/changes/add-zai-provider/specs/netclaw-model-providers/spec.md b/openspec/changes/add-zai-provider/specs/netclaw-model-providers/spec.md new file mode 100644 index 000000000..c55911c8a --- /dev/null +++ b/openspec/changes/add-zai-provider/specs/netclaw-model-providers/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: Z.ai provider + +The system SHALL support Z.ai as a selectable provider profile with the type key `zai`. The provider SHALL use `Microsoft.Extensions.AI.IChatClient` and the stable Z.ai OpenAI-compatible API. + +The provider SHALL require an API key. It SHALL send the key with HTTP Bearer authentication and SHALL NOT offer OAuth authentication. + +The default endpoint SHALL be `https://api.z.ai/api/coding/paas/v4`, the GLM Coding Plan base. Operators on the pay-as-you-go platform SHALL override `Endpoint` with `https://api.z.ai/api/paas/v4`. Chat requests SHALL use `/chat/completions`, and model discovery SHALL use `/models`. + +A provider base URL with a trailing `v` path segment SHALL be treated as already versioned. Endpoint resolution SHALL NOT append another version segment to such a base. + +#### Scenario: Operator adds a Z.ai provider + +- **WHEN** the operator adds a `zai` provider with an API key +- **THEN** Netclaw stores the provider profile in configuration +- **AND** Netclaw stores the API key through the encrypted secrets path +- **AND** runtime resolves the provider through `IChatClient` + +#### Scenario: Z.ai provider has no API key + +- **GIVEN** a `zai` provider has no API key +- **WHEN** configuration validation or a provider probe runs +- **THEN** validation fails with Z.ai-specific API-key guidance +- **AND** Netclaw does not select a real chat client + +#### Scenario: Z.ai model discovery + +- **GIVEN** a `zai` provider has a valid API key +- **WHEN** model discovery runs +- **THEN** Netclaw calls the configured `/models` endpoint with the exact Bearer token +- **AND** Netclaw returns the model IDs from the live response + +#### Scenario: Chat uses the versioned base without extra version segments + +- **GIVEN** a `zai` provider uses the default `https://api.z.ai/api/coding/paas/v4` base +- **WHEN** a chat completion request is sent +- **THEN** the request URL is `https://api.z.ai/api/coding/paas/v4/chat/completions` +- **AND** the URL does not contain a second version segment + +#### Scenario: Current Z.ai model capabilities + +- **WHEN** discovery returns `glm-5.3` +- **THEN** Netclaw assigns a one-million-token context window +- **AND** Netclaw assigns text input and output modalities + +#### Scenario: Previous Z.ai model capabilities + +- **WHEN** discovery returns `glm-5.2` +- **THEN** Netclaw assigns a 200,000-token context window +- **AND** Netclaw assigns text input and output modalities + +#### Scenario: Unknown Z.ai model metadata + +- **WHEN** discovery returns a Z.ai model ID without documented capability metadata, such as `glm-4.6` or `glm-5-turbo` +- **THEN** Netclaw leaves its context window unresolved +- **AND** Netclaw does not invent a context value + +### Requirement: Z.ai reasoning and tool-loop contract + +The Z.ai provider SHALL map MEAI reasoning options to Z.ai request fields. It SHALL preserve Z.ai reasoning content when an assistant tool call returns to the provider. + +The Z.ai provider SHALL NOT add Z.ai fields to generic OpenAI-compatible requests. It SHALL NOT send local-server fields to Z.ai. + +#### Scenario: Disable Z.ai reasoning + +- **WHEN** a request sets MEAI reasoning effort to `None` +- **THEN** the Z.ai request sets `thinking.type` to `disabled` + +#### Scenario: Enable Z.ai reasoning + +- **WHEN** a request sets low, medium, high, or extra-high MEAI reasoning effort +- **THEN** the Z.ai request sets `thinking.type` to `enabled` +- **AND** the request does not set a `reasoning_effort` field + +#### Scenario: Replay reasoning during a tool loop + +- **GIVEN** Z.ai returns reasoning content and a tool call +- **WHEN** Netclaw sends the tool result in the next request +- **THEN** the assistant history includes the returned `reasoning_content` +- **AND** the assistant history includes the original tool call + +#### Scenario: Generic provider payload remains unchanged + +- **WHEN** Netclaw sends a request through the generic OpenAI-compatible profile +- **THEN** it does not add Z.ai thinking or reasoning-replay fields +- **AND** it retains existing local-server request fields diff --git a/openspec/changes/add-zai-provider/tasks.md b/openspec/changes/add-zai-provider/tasks.md new file mode 100644 index 000000000..0afe6d24b --- /dev/null +++ b/openspec/changes/add-zai-provider/tasks.md @@ -0,0 +1,23 @@ +## 1. Provider Contract + +- [x] 1.1 Add the Z.ai descriptor, API-key probe, and known model metadata +- [x] 1.2 Add the Z.ai plugin and register it in all provider catalogs +- [x] 1.3 Default to the GLM Coding Plan endpoint; document the platform override +- [x] 1.4 Treat trailing `v` base segments as already versioned in endpoint resolution + +## 2. Wire Behavior + +- [x] 2.1 Add the Zai wire profile to the shared chat client +- [x] 2.2 Map MEAI reasoning options and Netclaw reasoning suppression to Z.ai fields +- [x] 2.3 Preserve Z.ai reasoning content during assistant tool-call replay + +## 3. Operator Surfaces + +- [x] 3.1 Add Z.ai to CLI and TUI provider coverage +- [x] 3.2 Update the model-provider specification and operations skill guidance + +## 4. Automated Proof + +- [x] 4.1 Add fake-HTTP tests for authentication, discovery, metadata, and errors +- [x] 4.2 Add payload tests for reasoning, tool-loop replay, and generic-profile isolation +- [ ] 4.3 Run focused tests, evals, the TUI smoke path, Slopwatch, and the header check diff --git a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs index 9f2260b41..2bd87df7b 100644 --- a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs @@ -51,8 +51,8 @@ public void DisplayProviders_ShowsAllKnownTypes() using var vm = CreateViewModel(); vm.RefreshDisplayProviders(); - Assert.Equal(8, vm.DisplayProviders.Count); - foreach (var type in new[] { "ollama", "openai", "anthropic", "openrouter", "openai-compatible", "github-copilot", "veniceai", "deepseek" }) + Assert.Equal(9, vm.DisplayProviders.Count); + foreach (var type in new[] { "ollama", "openai", "anthropic", "openrouter", "openai-compatible", "github-copilot", "veniceai", "deepseek", "zai" }) { Assert.Contains(vm.DisplayProviders, p => p.ProviderType == type); } @@ -94,7 +94,7 @@ public void DisplayProviders_MergesConfiguredWithKnown() vm.RefreshDisplayProviders(); // All known types present - Assert.Equal(8, vm.DisplayProviders.Count); + Assert.Equal(9, vm.DisplayProviders.Count); // openrouter is configured var openrouter = vm.DisplayProviders.First(p => p.ProviderType == "openrouter"); @@ -1205,8 +1205,8 @@ public void DisplayProviders_ShowsMultipleInstancesOfSameType() // Other unconfigured types should still be present Assert.Contains(vm.DisplayProviders, p => p.ProviderType == "ollama" && !p.IsConfigured); - // Total: 2 configured + 7 unconfigured types = 9 - Assert.Equal(9, vm.DisplayProviders.Count); + // Total: 2 configured + 8 unconfigured types = 10 + Assert.Equal(10, vm.DisplayProviders.Count); } [Fact] diff --git a/src/Netclaw.Configuration/ModelIdNormalizer.cs b/src/Netclaw.Configuration/ModelIdNormalizer.cs index 20b3af62b..6c0e7afd3 100644 --- a/src/Netclaw.Configuration/ModelIdNormalizer.cs +++ b/src/Netclaw.Configuration/ModelIdNormalizer.cs @@ -41,6 +41,7 @@ public static partial class ModelIdNormalizer ["mixtral"] = "mistralai", ["qwen"] = "qwen", ["deepseek"] = "deepseek", + ["glm"] = "zai", ["phi"] = "microsoft", }; diff --git a/src/Netclaw.Daemon.Tests/Providers/OpenAiCompatibleEndpointTests.cs b/src/Netclaw.Daemon.Tests/Providers/OpenAiCompatibleEndpointTests.cs new file mode 100644 index 000000000..d631bdea9 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Providers/OpenAiCompatibleEndpointTests.cs @@ -0,0 +1,60 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Providers.SelfHosted; +using Xunit; + +namespace Netclaw.Daemon.Tests.Providers; + +public sealed class OpenAiCompatibleEndpointTests +{ + [Theory] + [InlineData("https://api.z.ai/api/coding/paas/v4", "/api/coding/paas/v4/chat/completions", "/api/coding/paas/v4/models")] + [InlineData("https://api.z.ai/api/paas/v4", "/api/paas/v4/chat/completions", "/api/paas/v4/models")] + [InlineData("https://api.deepseek.com/v1", "/v1/chat/completions", "/v1/models")] + [InlineData("http://localhost:8000/api/v1", "/api/v1/chat/completions", "/api/v1/models")] + [InlineData("https://example.test/v2", "/v2/chat/completions", "/v2/models")] + [InlineData("https://example.test/v4/", "/v4/chat/completions", "/v4/models")] + public void FromBaseUrl_TrailingVersionSegmentIsAlreadyVersioned( + string endpoint, string expectedChatPath, string expectedModelsPath) + { + var result = OpenAiCompatibleEndpoint.FromBaseUrl(endpoint); + + Assert.Equal(expectedChatPath, result.ChatCompletionsPath); + Assert.Equal(expectedModelsPath, result.ModelsPath); + } + + [Theory] + [InlineData("http://localhost:8000", "/v1/chat/completions", "/v1/models")] + [InlineData("http://localhost:8000/", "/v1/chat/completions", "/v1/models")] + [InlineData("http://localhost:8000/edge", "/edge/v1/chat/completions", "/edge/v1/models")] + [InlineData("https://example.test/vendor", "/vendor/v1/chat/completions", "/vendor/v1/models")] + public void FromBaseUrl_UnversionedBaseGetsV1Default( + string endpoint, string expectedChatPath, string expectedModelsPath) + { + var result = OpenAiCompatibleEndpoint.FromBaseUrl(endpoint); + + Assert.Equal(expectedChatPath, result.ChatCompletionsPath); + Assert.Equal(expectedModelsPath, result.ModelsPath); + } + + [Fact] + public void FromBaseUrl_DoesNotTreatVersionLikeWordsAsVersions() + { + // A segment like "vendor" or "vpreview" must not suppress the /v1 default. + var result = OpenAiCompatibleEndpoint.FromBaseUrl("https://example.test/vpreview"); + + Assert.Equal("/vpreview/v1/chat/completions", result.ChatCompletionsPath); + } + + [Fact] + public void FromBaseUrl_PassesApiKeyThrough() + { + var result = OpenAiCompatibleEndpoint.FromBaseUrl("https://api.z.ai/api/coding/paas/v4", "key"); + + Assert.Equal("key", result.ApiKey); + Assert.Equal("https://api.z.ai/api/coding/paas/v4", result.BaseUri.AbsoluteUri); + } +} diff --git a/src/Netclaw.Daemon.Tests/Providers/ZaiProviderTests.cs b/src/Netclaw.Daemon.Tests/Providers/ZaiProviderTests.cs new file mode 100644 index 000000000..384b82c70 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Providers/ZaiProviderTests.cs @@ -0,0 +1,240 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Netclaw.Configuration; +using Netclaw.Providers.SelfHosted; +using Netclaw.Providers.Zai; +using Xunit; + +namespace Netclaw.Daemon.Tests.Providers; + +public sealed class ZaiProviderTests +{ + [Fact] + public async Task ProbeRequiresApiKey() + { + var descriptor = new ZaiDescriptor(new HttpClient()); + + var result = await descriptor.ProbeAsync( + new ProviderEntry(), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains("API key is required", result.ErrorMessage); + } + + [Fact] + public async Task ProbeSendsBearerTokenAndAddsKnownMetadata() + { + HttpRequestMessage? captured = null; + using var handler = new RecordingHandler(request => + { + captured = request; + // Shape captured from the live coding-plan /models response. + return JsonResponse(""" + {"data":[{"id":"glm-5.3"},{"id":"glm-5.2"},{"id":"glm-4.6"},{"id":"glm-5-turbo"}]} + """); + }); + using var http = new HttpClient(handler); + var descriptor = new ZaiDescriptor(http); + + var result = await descriptor.ProbeAsync(new ProviderEntry + { + Endpoint = "https://api.z.ai/api/coding/paas/v4", + ApiKey = new SensitiveString("test-zai-key") + }, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal("https://api.z.ai/api/coding/paas/v4/models", captured!.RequestUri!.AbsoluteUri); + Assert.Equal("Bearer", captured.Headers.Authorization!.Scheme); + Assert.Equal("test-zai-key", captured.Headers.Authorization.Parameter); + + var flagship = Assert.Single(result.Models, model => model.ModelId.Value == "glm-5.3"); + Assert.Equal(1_000_000, flagship.ContextWindowTokens); + Assert.Equal(ModelModality.Text, flagship.InputModalities); + Assert.Equal(ModelModality.Text, flagship.OutputModalities); + + var previous = Assert.Single(result.Models, model => model.ModelId.Value == "glm-5.2"); + Assert.Equal(200_000, previous.ContextWindowTokens); + Assert.Equal(ModelModality.Text, previous.InputModalities); + Assert.Equal(ModelModality.Text, previous.OutputModalities); + + // Undocumented ids keep unresolved metadata — no invented fallback. + var undocumented = Assert.Single(result.Models, model => model.ModelId.Value == "glm-4.6"); + Assert.Null(undocumented.ContextWindowTokens); + Assert.Null(undocumented.InputModalities); + Assert.Null(undocumented.OutputModalities); + + Assert.Null( + Assert.Single(result.Models, model => model.ModelId.Value == "glm-5-turbo").ContextWindowTokens); + } + + [Theory] + [InlineData(ReasoningEffort.None, "disabled")] + [InlineData(ReasoningEffort.Low, "enabled")] + [InlineData(ReasoningEffort.Medium, "enabled")] + [InlineData(ReasoningEffort.High, "enabled")] + [InlineData(ReasoningEffort.ExtraHigh, "enabled")] + public async Task ZaiProfileMapsReasoningEffort(ReasoningEffort effort, string thinkingType) + { + string? body = null; + using var handler = new RecordingHandler(request => + { + body = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return ChatResponse(); + }); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.z.ai") }; + var endpoint = OpenAiCompatibleEndpoint.FromBaseUrl( + "https://api.z.ai/api/coding/paas/v4", "test-zai-key"); + var client = new OpenAiCompatibleChatClient( + http, endpoint, "glm-5.3", OpenAiCompatibleWireProfile.Zai); + + await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "hello")], + new ChatOptions { Reasoning = new ReasoningOptions { Effort = effort } }, + TestContext.Current.CancellationToken); + + // The coding-plan base already pins v4 — chat must not append another v1. + Assert.Equal( + "https://api.z.ai/api/coding/paas/v4/chat/completions", + handler.Requests[0].RequestUri!.AbsoluteUri); + using var document = JsonDocument.Parse(body!); + var root = document.RootElement; + Assert.Equal(thinkingType, root.GetProperty("thinking").GetProperty("type").GetString()); + // Z.ai exposes a binary thinking toggle only; it has no reasoning_effort field. + Assert.False(root.TryGetProperty("reasoning_effort", out _)); + Assert.Equal("test-zai-key", handler.Requests[0].Headers.Authorization!.Parameter); + } + + [Fact] + public void ZaiProfileReplaysReasoningWithToolCall() + { + var message = new ChatMessage(ChatRole.Assistant, + [ + new TextReasoningContent("tool analysis"), + new FunctionCallContent("call-1", "get_status", new Dictionary()) + ]); + + var serialized = OpenAiCompatibleChatClient.ToMessage( + message, OpenAiCompatibleWireProfile.Zai); + + Assert.Equal("tool analysis", serialized["reasoning_content"]!.GetValue()); + Assert.Single(serialized["tool_calls"]!.AsArray()); + } + + [Fact] + public void ZaiPluginRejectsOAuthToken() + { + using var http = new HttpClient(); + var plugin = new ZaiProviderPlugin( + new ZaiDescriptor(http), + Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + var entry = new ProviderEntry + { + OAuthAccessToken = new SensitiveString("oauth-token") + }; + var model = new ModelReference + { + Provider = "zai", + ModelId = "glm-5.3" + }; + + var exception = Assert.Throws( + () => plugin.CreateChatClient(entry, model)); + + Assert.Contains("requires an API key", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("content_filter")] + [InlineData("insufficient_system_resource")] + public async Task ZaiProfilePreservesTerminalFinishReason(string finishReason) + { + using var handler = new RecordingHandler(_ => JsonResponse($$$""" + {"id":"response-1","model":"glm-5.3","choices":[{"finish_reason":"{{{finishReason}}}","message":{"role":"assistant","content":null}}]} + """)); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.z.ai") }; + var endpoint = OpenAiCompatibleEndpoint.FromBaseUrl( + "https://api.z.ai/api/coding/paas/v4", "test-zai-key"); + var client = new OpenAiCompatibleChatClient( + http, endpoint, "glm-5.3", OpenAiCompatibleWireProfile.Zai); + + var response = await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "hello")], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(finishReason, response.FinishReason?.Value); + } + + [Fact] + public async Task WireProfilesKeepProviderFieldsIsolated() + { + var bodies = new List(); + using var handler = new RecordingHandler(request => + { + bodies.Add(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream") + }; + }); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.test") }; + var endpoint = OpenAiCompatibleEndpoint.FromBaseUrl("https://example.test/v1"); + var generic = new OpenAiCompatibleChatClient( + http, endpoint, "model", OpenAiCompatibleWireProfile.Generic); + var zai = new OpenAiCompatibleChatClient( + http, endpoint, "model", OpenAiCompatibleWireProfile.Zai); + var options = new ChatOptions { Reasoning = new ReasoningOptions { Effort = ReasoningEffort.High } }; + + await DrainAsync(generic.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "hello")], options, + TestContext.Current.CancellationToken)); + await DrainAsync(zai.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "hello")], options, + TestContext.Current.CancellationToken)); + + using var genericBody = JsonDocument.Parse(bodies[0]); + Assert.True(genericBody.RootElement.GetProperty("return_progress").GetBoolean()); + Assert.False(genericBody.RootElement.TryGetProperty("thinking", out _)); + + using var zaiBody = JsonDocument.Parse(bodies[1]); + Assert.False(zaiBody.RootElement.TryGetProperty("return_progress", out _)); + Assert.Equal("enabled", zaiBody.RootElement.GetProperty("thinking").GetProperty("type").GetString()); + Assert.False(zaiBody.RootElement.TryGetProperty("reasoning_effort", out _)); + } + + private static async Task DrainAsync(IAsyncEnumerable updates) + { + await foreach (var _ in updates) + { + } + } + + private static HttpResponseMessage ChatResponse() => JsonResponse(""" + {"id":"response-1","model":"glm-5.3","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}]} + """); + + private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private sealed class RecordingHandler( + Func handler) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(handler(request)); + } + } +} diff --git a/src/Netclaw.Daemon/Configuration/ProviderPluginFactory.cs b/src/Netclaw.Daemon/Configuration/ProviderPluginFactory.cs index 72693255d..174818225 100644 --- a/src/Netclaw.Daemon/Configuration/ProviderPluginFactory.cs +++ b/src/Netclaw.Daemon/Configuration/ProviderPluginFactory.cs @@ -166,6 +166,10 @@ private void ApplyDialect(ChatOptions? options) properties["thinking"] = new Dictionary { ["type"] = "disabled" }; break; + case ReasoningSuppressionDialect.ZaiThinking: + properties["thinking"] = new Dictionary { ["type"] = "disabled" }; + break; + case ReasoningSuppressionDialect.None: default: break; diff --git a/src/Netclaw.Providers/ILlmProviderPlugin.cs b/src/Netclaw.Providers/ILlmProviderPlugin.cs index 46edbcb68..b0558b933 100644 --- a/src/Netclaw.Providers/ILlmProviderPlugin.cs +++ b/src/Netclaw.Providers/ILlmProviderPlugin.cs @@ -77,4 +77,10 @@ public enum ReasoningSuppressionDialect /// DeepSeek's hosted API: emits top-level thinking: { type: "disabled" }. /// DeepSeekThinking, + + /// + /// Z.ai's hosted API (GLM): emits top-level thinking: { type: "disabled" }. + /// Same wire shape as DeepSeek; kept distinct so each provider's dialect is named. + /// + ZaiThinking, } diff --git a/src/Netclaw.Providers/LlmProviderServiceExtensions.cs b/src/Netclaw.Providers/LlmProviderServiceExtensions.cs index de79436fd..c8e13d3ac 100644 --- a/src/Netclaw.Providers/LlmProviderServiceExtensions.cs +++ b/src/Netclaw.Providers/LlmProviderServiceExtensions.cs @@ -14,6 +14,7 @@ using Netclaw.Providers.OpenRouter; using Netclaw.Providers.SelfHosted; using Netclaw.Providers.VeniceAi; +using Netclaw.Providers.Zai; namespace Netclaw.Providers; @@ -42,6 +43,7 @@ public static IServiceCollection AddLlmProviders(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); @@ -51,6 +53,7 @@ public static IServiceCollection AddLlmProviders(this IServiceCollection service services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); return services; } diff --git a/src/Netclaw.Providers/ProviderDescriptorCatalog.cs b/src/Netclaw.Providers/ProviderDescriptorCatalog.cs index 68911c376..7f9b97467 100644 --- a/src/Netclaw.Providers/ProviderDescriptorCatalog.cs +++ b/src/Netclaw.Providers/ProviderDescriptorCatalog.cs @@ -10,6 +10,7 @@ using Netclaw.Providers.OpenRouter; using Netclaw.Providers.SelfHosted; using Netclaw.Providers.VeniceAi; +using Netclaw.Providers.Zai; namespace Netclaw.Providers; @@ -30,6 +31,7 @@ private ProviderDescriptorCatalog(IReadOnlyList descriptors GitHubCopilot = GetRequired(descriptors); VeniceAi = GetRequired(descriptors); DeepSeek = GetRequired(descriptors); + Zai = GetRequired(descriptors); } public OllamaDescriptor Ollama { get; } @@ -48,6 +50,8 @@ private ProviderDescriptorCatalog(IReadOnlyList descriptors public DeepSeekDescriptor DeepSeek { get; } + public ZaiDescriptor Zai { get; } + public IReadOnlyList All { get; } public static ProviderDescriptorCatalog Create( @@ -67,6 +71,7 @@ public static ProviderDescriptorCatalog Create( new GitHubCopilotDescriptor(httpClient, copilotTokenExchanger), new VeniceAiDescriptor(httpClient), new DeepSeekDescriptor(httpClient), + new ZaiDescriptor(httpClient), ]); } diff --git a/src/Netclaw.Providers/ProviderDescriptorServiceExtensions.cs b/src/Netclaw.Providers/ProviderDescriptorServiceExtensions.cs index 8eab21f9b..0e0140aea 100644 --- a/src/Netclaw.Providers/ProviderDescriptorServiceExtensions.cs +++ b/src/Netclaw.Providers/ProviderDescriptorServiceExtensions.cs @@ -12,6 +12,7 @@ using Netclaw.Providers.OpenAi; using Netclaw.Providers.OpenRouter; using Netclaw.Providers.SelfHosted; +using Netclaw.Providers.Zai; namespace Netclaw.Providers; @@ -54,6 +55,7 @@ public static IServiceCollection AddProviderDescriptors(this IServiceCollection services.AddSingleton(sp => sp.GetRequiredService().GitHubCopilot); services.AddSingleton(sp => sp.GetRequiredService().VeniceAi); services.AddSingleton(sp => sp.GetRequiredService().DeepSeek); + services.AddSingleton(sp => sp.GetRequiredService().Zai); services.AddSingleton(sp => sp.GetRequiredService().Ollama); services.AddSingleton(sp => sp.GetRequiredService().OpenAiCompatible); @@ -63,6 +65,7 @@ public static IServiceCollection AddProviderDescriptors(this IServiceCollection services.AddSingleton(sp => sp.GetRequiredService().GitHubCopilot); services.AddSingleton(sp => sp.GetRequiredService().VeniceAi); services.AddSingleton(sp => sp.GetRequiredService().DeepSeek); + services.AddSingleton(sp => sp.GetRequiredService().Zai); services.AddSingleton(sp => new ProviderDescriptorRegistry(sp.GetRequiredService().All)); diff --git a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleChatClient.cs b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleChatClient.cs index f215c1d47..e99ad1454 100644 --- a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleChatClient.cs +++ b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleChatClient.cs @@ -22,6 +22,10 @@ public enum OpenAiCompatibleWireProfile // DeepSeek requires a thinking field and reasoning_content replay rules. // The generic OpenAI-compatible payload does not apply these rules. DeepSeek, + // Z.ai (GLM) uses the same thinking shape and reasoning_content replay as + // DeepSeek, but maps reasoning effort to thinking enabled/disabled only — + // Z.ai has no reasoning_effort gradation field. + Zai, } public sealed class OpenAiCompatibleChatClient : IChatClient @@ -258,6 +262,8 @@ private JsonObject BuildPayload(IEnumerable messages, ChatOptions? if (_wireProfile == OpenAiCompatibleWireProfile.DeepSeek) ApplyDeepSeekReasoning(body, options?.Reasoning?.Effort); + else if (_wireProfile == OpenAiCompatibleWireProfile.Zai) + ApplyZaiReasoning(body, options?.Reasoning?.Effort); // Pass through additional properties as top-level JSON fields. // Enables provider-specific options like chat_template_kwargs for llama.cpp. @@ -298,6 +304,19 @@ private static void ApplyDeepSeekReasoning(JsonObject body, ReasoningEffort? eff } } + // Z.ai exposes a binary thinking toggle only; it has no reasoning_effort + // gradation. Any non-null effort enables thinking, None disables it. + private static void ApplyZaiReasoning(JsonObject body, ReasoningEffort? effort) + { + var type = effort switch + { + null => "enabled", + ReasoningEffort.None => "disabled", + _ => "enabled", + }; + body["thinking"] = new JsonObject { ["type"] = type }; + } + /// /// Emits per-turn byte hashes for the static portion of the outbound LLM /// request so KV cache prefix drift can be diagnosed from the daemon log. @@ -524,7 +543,8 @@ internal static JsonObject ToMessage( break; case TextReasoningContent reasoning - when wireProfile == OpenAiCompatibleWireProfile.DeepSeek + when (wireProfile == OpenAiCompatibleWireProfile.DeepSeek + || wireProfile == OpenAiCompatibleWireProfile.Zai) && !string.IsNullOrEmpty(reasoning.Text): reasoningSegments.Add(reasoning.Text); break; diff --git a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs index 12658c81f..5eec87384 100644 --- a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs +++ b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs @@ -16,8 +16,11 @@ public static OpenAiCompatibleEndpoint FromBaseUrl(string endpoint, string? apiK var baseUri = new Uri(endpoint.TrimEnd('/')); var basePath = baseUri.AbsolutePath.TrimEnd('/'); - if (basePath.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase) - || basePath.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + // A trailing version segment (v1, v4, ...) means the operator already + // pinned an API version — appending another "v1/..." would produce a + // /v4/v1/chat/completions 404 on hosts like api.z.ai. Bare hosts and + // unversioned paths keep the /v1 default below. + if (HasVersionedSuffix(basePath)) { return new OpenAiCompatibleEndpoint( baseUri, @@ -33,6 +36,18 @@ public static OpenAiCompatibleEndpoint FromBaseUrl(string endpoint, string? apiK ApiKey: apiKey); } + private static bool HasVersionedSuffix(string basePath) + { + var lastSlash = basePath.LastIndexOf('/'); + if (lastSlash < 0) + return false; + + var segment = basePath[(lastSlash + 1)..]; + return segment.Length > 1 + && (segment[0] == 'v' || segment[0] == 'V') + && segment[1..].All(char.IsDigit); + } + private static string Combine(string basePath, string suffix) { if (string.IsNullOrWhiteSpace(basePath) || basePath == "/") diff --git a/src/Netclaw.Providers/Zai/ZaiDescriptor.cs b/src/Netclaw.Providers/Zai/ZaiDescriptor.cs new file mode 100644 index 000000000..9829e41b2 --- /dev/null +++ b/src/Netclaw.Providers/Zai/ZaiDescriptor.cs @@ -0,0 +1,79 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net.Http.Headers; +using Netclaw.Configuration; + +namespace Netclaw.Providers.Zai; + +/// +/// Provider descriptor for Z.ai's hosted OpenAI-compatible API (GLM models). +/// Default endpoint targets the GLM Coding Plan; pay-as-you-go operators +/// override Endpoint with https://api.z.ai/api/paas/v4. +/// +public sealed class ZaiDescriptor(HttpClient httpClient) : IProviderDescriptor +{ + private const int FlagshipContextWindow = 1_000_000; + private const int PreviousContextWindow = 200_000; + + public string TypeKey => "zai"; + + public string DisplayName => "Z.ai"; + + public string DefaultEndpoint => "https://api.z.ai/api/coding/paas/v4"; + + public string ModelListingPath => "/models"; + + public IProviderAuth Auth { get; } = new ApiKeyAuth + { + GuidanceUrl = new Uri("https://z.ai/manage-apikey/apikey-list"), + }; + + public Task ProbeAsync(ProviderEntry entry, CancellationToken ct = default) + { + var apiKey = entry.ApiKey?.Value; + if (string.IsNullOrWhiteSpace(apiKey)) + { + return Task.FromResult(new ProviderProbeResult( + false, + "API key is required for Z.ai. Get one at https://z.ai/manage-apikey/apikey-list", + [])); + } + + return ProbeHelpers.ExecuteProbeAsync( + httpClient, + TypeKey, + DefaultEndpoint, + ModelListingPath, + entry.Endpoint, + request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey), + ParseModels, + ct); + } + + internal static ProviderProbeResult ParseModels(string json) + { + var parsed = ProbeHelpers.ParseOpenAiStyleModels(json); + var models = parsed.Models + .Select(model => KnownModelContextWindows.TryGetValue(model.ModelId.Value, out var contextWindow) + ? model with + { + ContextWindowTokens = contextWindow, + InputModalities = ModelModality.Text, + OutputModalities = ModelModality.Text, + } + : model) + .ToArray(); + + return parsed with { Models = models }; + } + + private static readonly Dictionary KnownModelContextWindows = + new(StringComparer.Ordinal) + { + ["glm-5.3"] = FlagshipContextWindow, + ["glm-5.2"] = PreviousContextWindow, + }; +} diff --git a/src/Netclaw.Providers/Zai/ZaiProviderPlugin.cs b/src/Netclaw.Providers/Zai/ZaiProviderPlugin.cs new file mode 100644 index 000000000..529adcd6d --- /dev/null +++ b/src/Netclaw.Providers/Zai/ZaiProviderPlugin.cs @@ -0,0 +1,43 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Netclaw.Configuration; +using Netclaw.Providers.SelfHosted; + +namespace Netclaw.Providers.Zai; + +/// +/// Daemon-side plugin for Z.ai's hosted API. +/// +public sealed class ZaiProviderPlugin( + ZaiDescriptor descriptor, + ILoggerFactory loggerFactory) : ProviderPluginBase(descriptor) +{ + public override IChatClient CreateChatClient(ProviderEntry entry, ModelReference model) + { + var apiKey = entry.ApiKey?.Value; + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException( + $"Provider type '{TypeKey}' requires an API key. Configure ApiKey in secrets.json."); + } + + var endpoint = OpenAiCompatibleEndpoint.FromBaseUrl( + string.IsNullOrWhiteSpace(entry.Endpoint) ? DefaultEndpoint : entry.Endpoint, + apiKey); + + return new OpenAiCompatibleChatClient( + CreateLlmHttpClient(endpoint.BaseUri), + endpoint, + model.ModelId, + OpenAiCompatibleWireProfile.Zai, + loggerFactory.CreateLogger()); + } + + public override ReasoningSuppressionDialect SuppressionDialect => + ReasoningSuppressionDialect.ZaiThinking; +} diff --git a/tests/smoke/tapes/init-wizard.tape b/tests/smoke/tapes/init-wizard.tape index 771a302c6..cfe6d5122 100644 --- a/tests/smoke/tapes/init-wizard.tape +++ b/tests/smoke/tapes/init-wizard.tape @@ -22,7 +22,7 @@ Enter # ─── Step 1 of 4: Provider ────────────────────────────────────────── Wait+Screen@10s /Choose your LLM provider:/ # Provider list ordering is alphabetical by TypeKey: -# anthropic, deepseek, github-copilot, ollama, openai, openai-compatible, openrouter, veniceai +# anthropic, deepseek, github-copilot, ollama, openai, openai-compatible, openrouter, veniceai, zai # Three Downs from the Anthropic default land on Ollama. Down 3 Enter diff --git a/tests/smoke/tapes/provider-add.tape b/tests/smoke/tapes/provider-add.tape index c854f5f8c..613bbb2cf 100644 --- a/tests/smoke/tapes/provider-add.tape +++ b/tests/smoke/tapes/provider-add.tape @@ -31,7 +31,7 @@ Wait+Screen@10s /Ollama/ Sleep 300ms # Provider type list (alphabetical by TypeKey): anthropic, deepseek, -# github-copilot, ollama, openai, openai-compatible, openrouter, veniceai. With +# github-copilot, ollama, openai, openai-compatible, openrouter, veniceai, zai. With # Anthropic highlighted by default, three Downs land on Ollama. Down 3 Enter diff --git a/tests/smoke/tapes/screenshots/wizard-screens.tape b/tests/smoke/tapes/screenshots/wizard-screens.tape index c35de7e83..479b7a853 100644 --- a/tests/smoke/tapes/screenshots/wizard-screens.tape +++ b/tests/smoke/tapes/screenshots/wizard-screens.tape @@ -37,7 +37,7 @@ Wait+Screen@5s /8\. Venice\.ai/ Screenshot "/tmp/shot-wizard-provider-picker.png" # Provider list ordering is alphabetical by TypeKey: -# anthropic, deepseek, github-copilot, ollama, openai, openai-compatible, openrouter, veniceai +# anthropic, deepseek, github-copilot, ollama, openai, openai-compatible, openrouter, veniceai, zai # Anthropic is the default; three Downs land on Ollama. Two Downs land on # github-copilot, whose OAuth flow would stop this capture-only tape. Down 3 From 0d1e703b8320067c9289dd80c1ad1090d8425fbe Mon Sep 17 00:00:00 2001 From: Moaaz Tarek Date: Wed, 19 Aug 2026 12:30:59 +0300 Subject: [PATCH 2/3] feat(provider): add optional API key support for openai-compatible provider --- .../references/providers.md | 2 +- .../add-openai-compatible-auth/.openspec.yaml | 2 + .../add-openai-compatible-auth/design.md | 111 ++++ .../add-openai-compatible-auth/proposal.md | 68 +++ .../specs/netclaw-model-providers/spec.md | 92 ++++ .../add-openai-compatible-auth/tasks.md | 85 +++ .../Doctor/ChatClientDoctorCheckTests.cs | 45 ++ .../Tui/OpenAiCompatibleAuthTests.cs | 515 ++++++++++++++++++ .../Doctor/ChatClientDoctorCheck.cs | 10 +- src/Netclaw.Cli/Provider/ProviderCommand.cs | 7 + src/Netclaw.Cli/Tui/OAuthFlowViews.cs | 8 +- src/Netclaw.Cli/Tui/ProviderManagerPage.cs | 134 ++++- .../Tui/ProviderManagerViewModel.cs | 65 ++- .../Tui/Wizard/Steps/ProviderStepView.cs | 44 +- .../Tui/Wizard/Steps/ProviderStepViewModel.cs | 11 +- src/Netclaw.Providers/IProviderAuth.cs | 14 +- .../SelfHosted/OpenAiCompatibleDescriptor.cs | 2 +- 17 files changed, 1195 insertions(+), 20 deletions(-) create mode 100644 openspec/changes/add-openai-compatible-auth/.openspec.yaml create mode 100644 openspec/changes/add-openai-compatible-auth/design.md create mode 100644 openspec/changes/add-openai-compatible-auth/proposal.md create mode 100644 openspec/changes/add-openai-compatible-auth/specs/netclaw-model-providers/spec.md create mode 100644 openspec/changes/add-openai-compatible-auth/tasks.md create mode 100644 src/Netclaw.Cli.Tests/Tui/OpenAiCompatibleAuthTests.cs diff --git a/feeds/skills/.system/files/netclaw-operations/references/providers.md b/feeds/skills/.system/files/netclaw-operations/references/providers.md index 571bbcb54..08fb85e9b 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/providers.md +++ b/feeds/skills/.system/files/netclaw-operations/references/providers.md @@ -22,7 +22,7 @@ and a `type` (well-known identifier). Manage them with `netclaw provider`: |------|------|-------| | `ollama` | Endpoint only | `--endpoint http://host:11434` | | `openai` | API key **or** OAuth (ChatGPT sub) | Codex backend for OAuth path | -| `openai-compatible` | Endpoint; optional API key | Generic OpenAI-shape proxies, llama.cpp, vLLM. Also DwarfStar (ds4): `--endpoint http://127.0.0.1:8000`, run `ds4-server` separately, model ids `deepseek-v4-flash` / `deepseek-v4-pro`, context window auto-detected | +| `openai-compatible` | Endpoint; optional API key (Bearer) | Generic OpenAI-shape proxies, llama.cpp, vLLM. Also DwarfStar (ds4): `--endpoint http://127.0.0.1:8000`, run `ds4-server` separately, model ids `deepseek-v4-flash` / `deepseek-v4-pro`, context window auto-detected. For gated endpoints (LiteLLM, intranet gateways), add `--api-key `; the key is stored in `secrets.json` and sent as `Authorization: Bearer`. `netclaw init` and the `netclaw provider` TUI offer the same choice ("No auth (local endpoint)" vs "API Key"). An entry that declares `AuthMethod: ApiKey` without a stored key is reported by `netclaw doctor`. | | `anthropic` | API key | `sk-ant-...` | | `openrouter` | API key | `sk-or-...` | | `github-copilot` | OAuth device flow only | Requires active Copilot subscription on the GitHub account | diff --git a/openspec/changes/add-openai-compatible-auth/.openspec.yaml b/openspec/changes/add-openai-compatible-auth/.openspec.yaml new file mode 100644 index 000000000..41c30bab8 --- /dev/null +++ b/openspec/changes/add-openai-compatible-auth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/add-openai-compatible-auth/design.md b/openspec/changes/add-openai-compatible-auth/design.md new file mode 100644 index 000000000..492c13876 --- /dev/null +++ b/openspec/changes/add-openai-compatible-auth/design.md @@ -0,0 +1,111 @@ +## Context + +The `openai-compatible` provider type (`OpenAiCompatibleDescriptor`, +TypeKey `openai-compatible`) targets self-hosted OpenAI-shaped backends. Its +transport already supports auth: `OpenAiCompatibleChatClient.BuildRequest` +sets `Authorization: Bearer` when `ApiKey` is present, and the probe, models +client, and capability resolver do the same. The CLI +(`netclaw provider add openai-compatible --endpoint --api-key `) +writes the key to encrypted secrets today. + +The gap is the auth declaration. `Auth` is `EndpointOnlyAuth` +(`SupportedAuthMethods = [AuthMethod.None]`), and the TUI drives off that: +- The wizard skips the auth sub-step when the method set is `[None]`. +- `OAuthFlowViews.BuildAuthMethodLabels` filters out `AuthMethod.None`. +- `ProviderStepView.BuildCredentialInput` and + `ProviderManagerPage.BuildCredentialsView` switch on the concrete auth type: + `EndpointOnlyAuth` shows endpoint only; every other shape shows API-key + only. No shape today means "endpoint plus optional key". + +So an operator cannot reach the already-working auth path from any +interactive surface, and the declared contract misdescribes the runtime. + +## Goals / Non-Goals + +**Goals:** + +- The `openai-compatible` auth method set is `[None, ApiKey]`. +- The wizard and the provider manager offer both auth choices with an + explicit "No auth" label for `None`. +- Credential screens show endpoint input plus an optional API-key input for + this shape. +- An entered key is probed with Bearer, persisted to encrypted secrets, and + recorded as `AuthMethod.ApiKey`; an empty key behaves exactly as today. +- Existing no-auth configurations are untouched in behavior. + +**Non-Goals:** + +- No transport change — the wire paths already send Bearer when a key exists. +- No non-Bearer header schemes (`api-key`, `x-api-key`, Azure-style). +- No per-instance display names, no new type keys, no OAuth for this shape. +- No config schema change (`Providers` is schema-open). + +## Decisions + +### D1: One new auth shape, not an extended `EndpointOnlyAuth` + +Add `EndpointOrApiKeyAuth : IProviderAuth` with +`SupportedAuthMethods = [AuthMethod.None, AuthMethod.ApiKey]`. + +Rationale: the TUI switches on concrete auth types +(`IProviderAuth` doc comment states this contract). Changing +`EndpointOnlyAuth` to carry two methods would flip every existing +endpoint-only consumer — including Ollama — into new UI paths. A distinct +shape confines the change to `openai-compatible`. +_Alternative rejected:_ making `EndpointOnlyAuth.SupportApiKey` configurable — +same concrete-type switch, but with hidden state that the TUI must also +switch on; two axes where one type each suffices. + +### D2: Method order — `None` first + +`SupportedAuthMethods = [None, ApiKey]` keeps "No auth" as the default +selection wherever the picker defaults to index 0. Local backends stay the +common case; auth is opt-in per instance. + +### D3: `None` gets an explicit auth-picker label + +`BuildAuthMethodLabels` currently drops `AuthMethod.None` because no +multi-method provider offered it. For this shape the wizard shows an auth +picker with two labeled choices; the label for `None` is "No auth (local +endpoint)". Selection drives which credential fields appear and which +`AuthMethod` is persisted. This also preserves the existing skip behavior: +single-`None` providers (Ollama) still bypass the picker entirely. + +### D4: Optional key input, not two sequential screens + +The credential screen for this shape shows the endpoint input first (Enter +advances), then the API-key input where an empty submit means "no key". +Empty submit stores no secret and persists `AuthMethod.None`. A non-empty +key persists `AuthMethod.ApiKey` and writes the secret. + +Rationale: one screen with a clear skip matches the "optional" contract and +avoids a modal question before every field. + +### D5: No new validation gate in the daemon + +Startup tri-state validation stays as is: a provider entry with +`AuthMethod.ApiKey` and a missing key must fail visibly through the existing +per-descriptor credential check (`ChatClientDoctorCheck.MissingCredentialMessage`), +not a new validator. The descriptor's `Auth` shape already declares that +`ApiKey` is one supported method, and the existing doctor logic handles +method/credential mismatch. + +## Risks / Trade-offs + +- **Concrete-type switches in TUI** — two views branch on the auth type today; + this adds a third branch in each. Accepted: the `IProviderAuth` contract + documents the switch. A generic field-driven auth model would be a larger + refactor with no additional behavior. +- **Smoke tape churn** — the wizard and provider-manager tapes drive the + provider flow by list index; a new auth sub-step changes the keystroke + sequence. Mitigation: update `init-wizard.tape` and `provider-add.tape` + in the same PR and run the light smoke suite. +- **Wrong method/credential combinations via hand-edited config** (for + example `AuthMethod: ApiKey` with no key) — pre-existing behavior; doctor + reports it. Not made worse by this change; covered by a fake-failure test + at the TUI save boundary. + +## Open Questions + +None — design decisions confirmed with the operator during planning: +extend `openai-compatible` (no new type key), optional key, Bearer only. diff --git a/openspec/changes/add-openai-compatible-auth/proposal.md b/openspec/changes/add-openai-compatible-auth/proposal.md new file mode 100644 index 000000000..92a70948a --- /dev/null +++ b/openspec/changes/add-openai-compatible-auth/proposal.md @@ -0,0 +1,68 @@ +## Why + +The `openai-compatible` provider type covers self-hosted OpenAI-shaped +backends such as llama.cpp, vLLM, Lemonade, and DwarfStar (ds4). The runtime +transport already sends `Authorization: Bearer` when an API key is present — +in the probe, the chat client, the models client, and the capability resolver. +But the descriptor declares `EndpointOnlyAuth`, so the auth method set is +`[None]` only. The init wizard and the provider manager TUI therefore never +offer a key input, and operators cannot configure an authenticated +OpenAI-compatible backend through any interactive surface. + +Many deployments need that key: gated intranet gateways, LiteLLM proxies, +and hosted OpenAI-compatible APIs all require Bearer auth. + +## What Changes + +- Add one `IProviderAuth` shape: `EndpointOrApiKeyAuth`, supporting + `[AuthMethod.None, AuthMethod.ApiKey]`. +- Change `OpenAiCompatibleDescriptor.Auth` from `EndpointOnlyAuth` to the new + shape. No runtime transport change — all wire paths already send Bearer + when a key exists and send no header when it does not. +- Init wizard: show the auth-method picker for this provider with an explicit + "No auth" choice; show endpoint input plus an optional API-key input; probe + and persist the key through the existing encrypted secrets path with + `AuthMethod.ApiKey`. +- Provider manager TUI (`netclaw provider`): the same two additions. +- Update the `netclaw-operations` system skill provider reference and bump its + version. +- Update smoke tapes that drive the wizard and provider-manager flows. +- No schema change: the `Providers` schema section is open. No new config + knob. Not breaking — existing `AuthMethod: None` entries keep their behavior. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `netclaw-model-providers`: the `openai-compatible` provider gains optional + API-key authentication. The auth method set becomes `[None, ApiKey]`. The + key is optional; an absent key sends no auth header. Interactive surfaces + (init wizard, provider manager) offer both choices and persist the key + through the encrypted secrets path. + +## Impact + +- **Code:** `src/Netclaw.Providers/IProviderAuth.cs` (new shape), + `src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs` (auth + declaration), `src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepView.cs` and + `ProviderStepViewModel.cs`, `src/Netclaw.Cli/Tui/OAuthFlowViews.cs` (auth + labels include `None`), `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs` + and `ProviderManagerPage.cs`. +- **Skill:** `feeds/skills/.system/files/netclaw-operations/references/providers.md` + with a `metadata.version` bump in the skill frontmatter. +- **Tests:** unit tests for the auth shape and the wizard/manager state + transitions; a fake-failure test proving a selected `ApiKey` method with an + empty key blocks save;. +- **No change:** transport code (`OpenAiCompatibleChatClient`, + `OpenAiCompatibleModelsClient`, `OpenAiCompatibleCapabilityResolver`), + CLI `provider add` surface (already accepts `--api-key`), config schema, + persistence, secrets format. +- **Traceability:** multi-provider support requirement in + `openspec/specs/netclaw-model-providers/spec.md`. +- **Out of scope:** non-Bearer header schemes (`api-key`, `x-api-key`), + per-instance display names, new provider type keys, OAuth for + OpenAI-compatible endpoints. diff --git a/openspec/changes/add-openai-compatible-auth/specs/netclaw-model-providers/spec.md b/openspec/changes/add-openai-compatible-auth/specs/netclaw-model-providers/spec.md new file mode 100644 index 000000000..8caa3246f --- /dev/null +++ b/openspec/changes/add-openai-compatible-auth/specs/netclaw-model-providers/spec.md @@ -0,0 +1,92 @@ +## MODIFIED Requirements + +### Requirement: Multi-provider support + +The system SHALL support selecting one provider profile from a supported set. +Supported provider type keys SHALL include `ollama`, `openai-compatible`, +`openrouter`, `openai`, `anthropic`, `github-copilot`, and `veniceai`. +All provider interactions SHALL use the Microsoft.Extensions.AI `IChatClient` +abstraction layer, ensuring provider-agnostic model access throughout the +application. + +Provider model discovery SHALL extract modality metadata where the provider +API supports it. `DiscoveredModel` records SHALL include `InputModalities` +and `OutputModalities` fields populated from provider responses. + +The `openai-compatible` provider SHALL support both no authentication and +API-key authentication. The API key SHALL be optional. When an API key is +configured, all OpenAI-compatible requests (chat completion, model +discovery, capability probing) SHALL send it as `Authorization: Bearer`. +When no API key is configured, requests SHALL send no authentication header. + +#### Scenario: Switch provider + +- **GIVEN** OpenRouter is configured +- **WHEN** operator selects Anthropic, OpenAI, Ollama, OpenAI-compatible, + OpenRouter, GitHub Copilot, or Venice.ai profile +- **THEN** runtime uses selected provider through the `IChatClient` interface + after validation + +#### Scenario: Provider accessed through MEAI abstraction + +- **GIVEN** a provider profile is configured +- **WHEN** the session actor sends a chat completion request +- **THEN** the request is routed through the `IChatClient` abstraction +- **AND** no provider-specific types leak into session or actor code + +#### Scenario: Ollama discovery includes modality + +- **GIVEN** an Ollama provider is configured +- **WHEN** model discovery runs via `ProviderProbe` +- **THEN** the returned `DiscoveredModel` records SHALL include + `InputModalities` and `OutputModalities` populated from `/api/show` + capability data + +#### Scenario: OpenRouter discovery includes modality + +- **GIVEN** an OpenRouter provider is configured +- **WHEN** model discovery runs via `ProviderProbe` +- **THEN** the returned `DiscoveredModel` records SHALL include + `InputModalities` and `OutputModalities` populated from + `architecture.input_modalities` and `architecture.output_modalities` + +#### Scenario: OpenAI-compatible discovery includes backend context metadata + +- **GIVEN** an OpenAI-compatible provider is configured +- **WHEN** model discovery runs via `ProviderProbe` +- **THEN** the returned `DiscoveredModel` records SHALL include context-window + metadata when the backend exposes a known field shape, including vLLM + `max_model_len`, DwarfStar/ds4 `context_length` or + `top_provider.context_length`, and llama.cpp `meta.n_ctx` or + `meta.n_ctx_train` + +#### Scenario: Add an OpenAI-compatible provider with an API key + +- **WHEN** the operator adds an `openai-compatible` provider and supplies an + API key through an interactive surface +- **THEN** Netclaw stores `AuthMethod: ApiKey` in the provider entry +- **AND** Netclaw stores the key through the encrypted secrets path +- **AND** chat, discovery, and probe requests send the key as + `Authorization: Bearer` + +#### Scenario: Add an OpenAI-compatible provider without an API key + +- **WHEN** the operator adds an `openai-compatible` provider and supplies no + API key +- **THEN** Netclaw stores `AuthMethod: None` and no provider secret +- **AND** chat, discovery, and probe requests send no authentication header + +#### Scenario: Existing no-auth OpenAI-compatible configuration + +- **GIVEN** an `openai-compatible` provider entry configured before this + change with no API key +- **WHEN** Netclaw loads the configuration after upgrade +- **THEN** the entry behaves exactly as before the upgrade + +#### Scenario: API-key auth declared without a stored key + +- **GIVEN** an `openai-compatible` provider entry declares + `AuthMethod: ApiKey` and no key is stored in secrets +- **WHEN** configuration diagnostics run +- **THEN** the failure is reported with provider-specific credential guidance +- **AND** Netclaw does not silently fall back to no-auth requests diff --git a/openspec/changes/add-openai-compatible-auth/tasks.md b/openspec/changes/add-openai-compatible-auth/tasks.md new file mode 100644 index 000000000..8bbd7d44c --- /dev/null +++ b/openspec/changes/add-openai-compatible-auth/tasks.md @@ -0,0 +1,85 @@ +## 1. Auth contract + +- [x] 1.1 Add `EndpointOrApiKeyAuth : IProviderAuth` in + `src/Netclaw.Providers/IProviderAuth.cs` with + `SupportedAuthMethods = [AuthMethod.None, AuthMethod.ApiKey]`. +- [x] 1.2 Change `OpenAiCompatibleDescriptor.Auth` from `EndpointOnlyAuth` + to `EndpointOrApiKeyAuth`. +- [x] 1.3 Confirmed no transport change is needed: chat client + (`OpenAiCompatibleChatClient.BuildRequest`), models client, capability + resolver, and descriptor probe send Bearer when a key exists and no header + when it does not. Untouched by this change. + +## 2. Init wizard + +- [x] 2.1 `OAuthFlowViews.BuildAuthMethodLabels`: `AuthMethod.None` renders + as "No auth (local endpoint)" and is included only for multi-method + providers; single-`None` providers (Ollama) still bypass the picker. + `ParseAuthMethodLabel` round-trips the new label. +- [x] 2.2 `ProviderStepView.BuildCredentialInput`: `EndpointOrApiKeyAuth` + branch — "No auth" shows endpoint input (sub-step 2 → probe); "API Key" + shows endpoint input then a new sub-step 10 for the key input. Back + navigation from 10 returns to 2. +- [x] 2.3 `ProviderStepViewModel`: `BuildProbeEntry` already carries + `ApiKey` when set (no change); `ContributeConfig`/`BuildProviderEntry` + now default the endpoint from descriptors of shape `EndpointOnlyAuth or + EndpointOrApiKeyAuth`; `WriteProviderCredentials` persists the selected + method and encrypted key via the existing `ProviderCredentialWriter`. +- [x] 2.4 `ChatClientDoctorCheck.MissingCredentialMessage`: an entry that + declares `AuthMethod: ApiKey` with no stored key now fails with guidance + (previously any provider supporting `None` skipped all credential checks). + +## 3. Provider manager TUI + +- [x] 3.1 `AdvanceAfterName` already routes multi-method providers to the + auth picker (no change needed); verified by test. +- [x] 3.2 `BuildAddAuthView` renders both labels via the shared + `BuildAuthMethodLabels` (covered by 2.1). +- [x] 3.3 New `AddCredentialsEndpoint` state + `BuildCredentialsEndpointView` + (endpoint stage before key stage for the ApiKey path); + `BuildCredentialsView` handles `EndpointOrApiKeyAuth` for both methods; + new `FixApiKey` state + `BuildFixApiKeyView` for repairing a key'd entry + (endpoint stage first, then key stage, via `SubmitFixEndpoint`). +- [x] 3.4 `WriteProviderConfig` persists via `ProviderCredentialWriter` with + the selected `NewAuthMethod` (no change needed); `SubmitFixCredentials` + key-required guard corrected to require a key only when the type is + key-only OR the entry declares `AuthMethod.ApiKey` (fixes a latent + regression where a no-auth openai-compatible entry would have demanded a + key). + +## 4. Tests + +- [x] 4.1 `OpenAiCompatibleAuthTests.OpenAiCompatible_Auth_SupportsNoneAndApiKeyInOrder`. +- [x] 4.2 Manager VM transitions: None → `AddCredentials`; ApiKey → + `AddCredentialsEndpoint` → key stage; empty-key ApiKey submit blocks. +- [x] 4.3 Fake-failure gates: `SubmitCredentials_...EmptyKey_BlocksBeforeProbe` + (no probe, no config write) and + `SubmitFixCredentials_...ApiKeyEntryWithEmptyKey_Blocks`. +- [x] 4.4 Wizard equivalents: probe entry carries key / no key; + `ContributeConfig` both methods; `WriteProviderCredentials` both methods + (encrypted secret asserted via `ENC:` prefix). +- [x] 4.5 Headless typed-key end-to-end: + `ManagerAddFlow_OpenAiCompatibleApiKey_TypedKeyEndToEnd` drives the real + page through type list → name → auth picker → endpoint → key → AddComplete + and asserts the persisted config. No `Thread.Sleep`/`Task.Delay` in + orchestration (polling via `Task.Yield` + cancellation). +- [x] 4.6 Doctor: + `ReturnsError_WhenOpenAiCompatibleDeclaresApiKeyWithoutStoredKey`, + `ReturnsPass_WhenOpenAiCompatibleUsesNoAuth`. + +## 5. Operator guidance + +- [x] 5.1 `feeds/skills/.system/files/netclaw-operations/references/providers.md`: + `openai-compatible` row documents Bearer, `--api-key`, the TUI choice, and + the doctor report for a declared-ApiKey-without-key entry. +- [x] 5.2 Skill version bumped 2.56.0 → 2.57.0. + +## 7. Quality gates + +- [x] 7.1 `dotnet build` clean (0 warnings). Netclaw.Cli.Tests 1389/1391 + (2 pre-existing environment skips); Netclaw.Daemon.Tests 1047/1047; + Netclaw.Configuration.Tests 604/604. +- [x] 7.2 `dotnet slopwatch analyze` — one pre-existing SW004 warning in + `PowerShellHostProbeTests.cs` (outside this diff, documented in a prior + change). No new violations. +- [x] 7.3 `./scripts/Add-FileHeaders.ps1 -Verify` — all files have headers. diff --git a/src/Netclaw.Cli.Tests/Doctor/ChatClientDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/ChatClientDoctorCheckTests.cs index 2433eab07..ddf840bcc 100644 --- a/src/Netclaw.Cli.Tests/Doctor/ChatClientDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/ChatClientDoctorCheckTests.cs @@ -351,6 +351,51 @@ public async Task EvaluatesBoundConfiguration_OnEnvOnlyInstance() } } + [Fact] + public async Task ReturnsError_WhenOpenAiCompatibleDeclaresApiKeyWithoutStoredKey() + { + var paths = CreatePathsWithConfig(""" + { + "configVersion": 1, + "Providers": { + "my-vllm": { "Type": "openai-compatible", "AuthMethod": "ApiKey", "Endpoint": "http://gpu.lan:8000" } + }, + "Models": { + "Main": { "Provider": "my-vllm", "ModelId": "qwen3:30b" } + } + } + """); + + var check = CreateCheck(paths); + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Error, result.Severity); + Assert.Contains("declares AuthMethod ApiKey", result.Message); + Assert.Contains("no ApiKey in secrets.json", result.Message); + } + + [Fact] + public async Task ReturnsPass_WhenOpenAiCompatibleUsesNoAuth() + { + var paths = CreatePathsWithConfig(""" + { + "configVersion": 1, + "Providers": { + "my-vllm": { "Type": "openai-compatible", "Endpoint": "http://gpu.lan:8000" } + }, + "Models": { + "Main": { "Provider": "my-vllm", "ModelId": "qwen3:30b" } + } + } + """); + + var check = CreateCheck(paths); + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + Assert.Contains("Real chat client configured", result.Message); + } + private static NetclawPaths CreatePathsWithConfig(string configJson) { var basePath = CreateTempBasePath(); diff --git a/src/Netclaw.Cli.Tests/Tui/OpenAiCompatibleAuthTests.cs b/src/Netclaw.Cli.Tests/Tui/OpenAiCompatibleAuthTests.cs new file mode 100644 index 000000000..4f46baa8a --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/OpenAiCompatibleAuthTests.cs @@ -0,0 +1,515 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Provider; +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +/// +/// Coverage for optional API-key auth on the openai-compatible provider: +/// auth shape declaration, auth-picker labels, wizard and provider-manager +/// state transitions, credential persistence, and a headless typed-key +/// end-to-end add flow. +/// +public sealed class OpenAiCompatibleAuthTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + private readonly FakeProviderProbe _fakeProbe = new(); + private readonly ProviderDescriptorRegistry _registry = ProviderCommand.CreateDefaultRegistry(); + + public OpenAiCompatibleAuthTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + // ── Auth shape ── + + [Fact] + public void OpenAiCompatible_Auth_SupportsNoneAndApiKeyInOrder() + { + var descriptor = _registry.Get("openai-compatible"); + + var auth = Assert.IsType(descriptor.Auth); + Assert.Equal([AuthMethod.None, AuthMethod.ApiKey], auth.SupportedAuthMethods); + } + + [Fact] + public void BuildAuthMethodLabels_IncludesNoneForOptionalAuthProvider() + { + var labels = OAuthFlowViews.BuildAuthMethodLabels(_registry.Get("openai-compatible").Auth); + + Assert.Equal(["No auth (local endpoint)", "API Key"], labels); + } + + [Fact] + public void BuildAuthMethodLabels_ExcludesNoneForSingleMethodProviders() + { + Assert.Empty(OAuthFlowViews.BuildAuthMethodLabels(_registry.Get("ollama").Auth)); + Assert.Equal(["API Key"], OAuthFlowViews.BuildAuthMethodLabels(_registry.Get("anthropic").Auth)); + } + + [Fact] + public void BuildAuthMethodLabels_UnchangedForMultiAuthWithoutNone() + { + var labels = OAuthFlowViews.BuildAuthMethodLabels(_registry.Get("openai").Auth); + + Assert.DoesNotContain(labels, l => l.Contains("No auth", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ParseAuthMethodLabel_RoundTripsNoAuthLabel() + { + var auth = _registry.Get("openai-compatible").Auth; + + Assert.Equal(AuthMethod.None, OAuthFlowViews.ParseAuthMethodLabel("No auth (local endpoint)", auth)); + Assert.Equal(AuthMethod.ApiKey, OAuthFlowViews.ParseAuthMethodLabel("API Key", auth)); + } + + // ── Provider manager state machine ── + + [Fact] + public void AdvanceAfterName_OpenAiCompatible_GoesToAuthSelect() + { + using var vm = CreateManagerVm(); + vm.StartAddForType("openai-compatible"); + + vm.AdvanceAfterName(); + + Assert.Equal(ProviderManagerState.AddSelectAuth, vm.CurrentState.Value); + } + + [Fact] + public void SelectAuthMethod_OpenAiCompatibleNone_GoesToCredentials() + { + using var vm = CreateManagerVm(); + vm.StartAddForType("openai-compatible"); + vm.AdvanceAfterName(); + + vm.SelectAuthMethod(AuthMethod.None); + + Assert.Equal(ProviderManagerState.AddCredentials, vm.CurrentState.Value); + } + + [Fact] + public void SelectAuthMethod_OpenAiCompatibleApiKey_GoesToEndpointStage() + { + using var vm = CreateManagerVm(); + vm.StartAddForType("openai-compatible"); + vm.AdvanceAfterName(); + + vm.SelectAuthMethod(AuthMethod.ApiKey); + + Assert.Equal(ProviderManagerState.AddCredentialsEndpoint, vm.CurrentState.Value); + } + + [Fact] + public void SubmitEndpointCredential_SetsEndpointAndAdvancesToKeyStage() + { + using var vm = CreateManagerVm(); + vm.StartAddForType("openai-compatible"); + vm.SelectAuthMethod(AuthMethod.ApiKey); + + vm.SubmitEndpointCredential("http://gpu.lan:8000"); + + Assert.Equal("http://gpu.lan:8000", vm.NewEndpoint); + Assert.Equal(ProviderManagerState.AddCredentials, vm.CurrentState.Value); + } + + [Fact] + public void SubmitCredentials_OpenAiCompatibleApiKeyWithEmptyKey_BlocksBeforeProbe() + { + // Fake-failure gate: declaring ApiKey without a key must block before + // any probe or persistence happens. + using var vm = CreateManagerVm(); + vm.StartAddForType("openai-compatible"); + vm.SelectAuthMethod(AuthMethod.ApiKey); + vm.SubmitEndpointCredential("http://gpu.lan:8000"); + vm.NewApiKey = null; + + vm.SubmitCredentials(); + + Assert.Equal(ProviderManagerState.AddCredentials, vm.CurrentState.Value); + Assert.Equal(0, _fakeProbe.ProbeCallCount); + Assert.Contains("API key is required", vm.StatusMessage.Value); + Assert.False(File.Exists(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task AddFlow_OpenAiCompatibleApiKey_PersistsMethodAndEncryptedSecret() + { + using var vm = CreateManagerVm(); + vm.StartAddForType("openai-compatible"); + vm.AdvanceAfterName(); + vm.SelectAuthMethod(AuthMethod.ApiKey); + vm.SubmitEndpointCredential("http://gpu.lan:8000"); + vm.NewApiKey = "sk-gateway-key"; + + vm.SubmitCredentials(); + await vm.ProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(ProviderManagerState.AddComplete, vm.CurrentState.Value); + Assert.Equal("sk-gateway-key", _fakeProbe.LastApiKey); + + var config = ReadConfig(); + var entry = config.GetProperty("Providers").GetProperty("my-openai-compatible"); + Assert.Equal("openai-compatible", entry.GetProperty("Type").GetString()); + Assert.Equal("ApiKey", entry.GetProperty("AuthMethod").GetString()); + Assert.Equal("http://gpu.lan:8000", entry.GetProperty("Endpoint").GetString()); + + var secrets = File.ReadAllText(_paths.SecretsPath); + Assert.Contains("ApiKey", secrets); + Assert.Contains("ENC:", secrets); + } + + [Fact] + public async Task AddFlow_OpenAiCompatibleNone_PersistsNoMethodAndNoSecret() + { + using var vm = CreateManagerVm(); + vm.StartAddForType("openai-compatible"); + vm.AdvanceAfterName(); + vm.SelectAuthMethod(AuthMethod.None); + vm.NewEndpoint = "http://gpu.lan:8000"; + + vm.SubmitCredentials(); + await vm.ProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(ProviderManagerState.AddComplete, vm.CurrentState.Value); + Assert.Null(_fakeProbe.LastApiKey); + + var config = ReadConfig(); + var entry = config.GetProperty("Providers").GetProperty("my-openai-compatible"); + Assert.Equal("openai-compatible", entry.GetProperty("Type").GetString()); + Assert.Equal("http://gpu.lan:8000", entry.GetProperty("Endpoint").GetString()); + Assert.False(entry.TryGetProperty("AuthMethod", out _)); + + if (File.Exists(_paths.SecretsPath)) + { + var secrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("ApiKey", secrets); + } + } + + // ── Provider manager fix flow ── + + [Fact] + public async Task SubmitFixCredentials_OpenAiCompatibleNoneEntry_DoesNotRequireKey() + { + WriteConfigProvider("my-vllm", "openai-compatible", authMethod: null); + using var vm = CreateManagerVm(); + vm.RefreshDisplayProviders(); + vm.DetailProvider = vm.DisplayProviders.Single(p => p.ConfiguredName == "my-vllm"); + vm.StartFixCredentials(vm.DetailProvider); + vm.FixApiKey = null; + vm.FixEndpoint = "http://gpu.lan:8001"; + + vm.SubmitFixCredentials(); + await vm.ProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // No key required: the fix probe ran (plus the eager re-probe after + // success) and the fix-flow success path completed. + Assert.True(_fakeProbe.ProbeCallCount >= 1); + Assert.Contains("Credentials updated", vm.StatusMessage.Value); + } + + [Fact] + public void SubmitFixCredentials_OpenAiCompatibleApiKeyEntryWithEmptyKey_Blocks() + { + WriteConfigProvider("my-vllm", "openai-compatible", authMethod: "ApiKey"); + using var vm = CreateManagerVm(); + vm.RefreshDisplayProviders(); + vm.DetailProvider = vm.DisplayProviders.Single(p => p.ConfiguredName == "my-vllm"); + vm.StartFixCredentials(vm.DetailProvider); + vm.FixApiKey = null; + + vm.SubmitFixCredentials(); + + Assert.Equal(ProviderManagerState.FixCredentials, vm.CurrentState.Value); + Assert.Equal(0, _fakeProbe.ProbeCallCount); + Assert.Contains("API key is required", vm.StatusMessage.Value); + } + + [Fact] + public void SubmitFixEndpoint_ApiKeyEntry_AdvancesToKeyStage() + { + WriteConfigProvider("my-vllm", "openai-compatible", authMethod: "ApiKey"); + using var vm = CreateManagerVm(); + vm.RefreshDisplayProviders(); + vm.DetailProvider = vm.DisplayProviders.Single(p => p.ConfiguredName == "my-vllm"); + vm.StartFixCredentials(vm.DetailProvider); + + vm.SubmitFixEndpoint("http://gpu.lan:8001"); + + Assert.Equal(ProviderManagerState.FixApiKey, vm.CurrentState.Value); + Assert.Equal("http://gpu.lan:8001", vm.FixEndpoint); + } + + // ── Wizard ── + + [Fact] + public void Wizard_TryGoBack_FromApiKeyStep_ReturnsToEndpoint() + { + using var step = new ProviderStepViewModel(_registry, _fakeProbe) + { + SelectedProviderType = "openai-compatible", + SelectedAuthMethod = AuthMethod.ApiKey, + }; + step.SetSubStep(10); + + Assert.True(step.TryGoBack()); + Assert.Equal(2, step.CurrentSubStep); + } + + [Fact] + public async Task Wizard_Probe_OpenAiCompatibleApiKey_CarriesKeyInProbeEntry() + { + using var step = new ProviderStepViewModel(_registry, _fakeProbe) + { + SelectedProviderType = "openai-compatible", + SelectedAuthMethod = AuthMethod.ApiKey, + EndpointInput = "http://gpu.lan:8000", + ApiKeyInput = "sk-gateway-key", + }; + + step.StartProbe(); + await step.ProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal("openai-compatible", _fakeProbe.LastProviderType); + Assert.Equal("sk-gateway-key", _fakeProbe.LastApiKey); + } + + [Fact] + public async Task Wizard_Probe_OpenAiCompatibleNone_SendsNoCredential() + { + using var step = new ProviderStepViewModel(_registry, _fakeProbe) + { + SelectedProviderType = "openai-compatible", + SelectedAuthMethod = AuthMethod.None, + EndpointInput = "http://gpu.lan:8000", + }; + + step.StartProbe(); + await step.ProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal("openai-compatible", _fakeProbe.LastProviderType); + Assert.Null(_fakeProbe.LastApiKey); + } + + [Fact] + public void Wizard_ContributeConfig_NoneMethod_DefaultsEndpointFromDescriptor() + { + using var step = new ProviderStepViewModel(_registry, _fakeProbe) + { + SelectedProviderType = "openai-compatible", + SelectedAuthMethod = AuthMethod.None, + SelectedModelId = "qwen3:30b", + }; + + var builder = new WizardConfigBuilder(_paths); + step.ContributeConfig(builder); + + Assert.Equal(AuthMethod.None, builder.Provider!.AuthMethod); + Assert.Equal(_registry.Get("openai-compatible").DefaultEndpoint, builder.Provider.Endpoint); + } + + [Fact] + public void Wizard_ContributeConfig_ApiKeyMethod_EmitsMethodAndEndpoint() + { + using var step = new ProviderStepViewModel(_registry, _fakeProbe) + { + SelectedProviderType = "openai-compatible", + SelectedAuthMethod = AuthMethod.ApiKey, + EndpointInput = "http://gpu.lan:8000", + ApiKeyInput = "sk-gateway-key", + }; + + var builder = new WizardConfigBuilder(_paths); + step.ContributeConfig(builder); + + Assert.Equal(AuthMethod.ApiKey, builder.Provider!.AuthMethod); + Assert.Equal("http://gpu.lan:8000", builder.Provider.Endpoint); + } + + [Fact] + public void Wizard_WriteProviderCredentials_None_WritesNoAuthMethodAndNoSecret() + { + using var step = new ProviderStepViewModel(_registry, _fakeProbe) + { + SelectedProviderType = "openai-compatible", + SelectedAuthMethod = AuthMethod.None, + EndpointInput = "http://gpu.lan:8000", + }; + + step.WriteProviderCredentials(_paths); + + var entry = ReadProviderEntry("openai-compatible"); + Assert.Equal("openai-compatible", entry.GetProperty("Type").GetString()); + Assert.Equal("http://gpu.lan:8000", entry.GetProperty("Endpoint").GetString()); + Assert.False(entry.TryGetProperty("AuthMethod", out _)); + + if (File.Exists(_paths.SecretsPath)) + { + var secrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("ApiKey", secrets); + } + } + + [Fact] + public void Wizard_WriteProviderCredentials_ApiKey_WritesMethodAndEncryptedSecret() + { + using var step = new ProviderStepViewModel(_registry, _fakeProbe) + { + SelectedProviderType = "openai-compatible", + SelectedAuthMethod = AuthMethod.ApiKey, + EndpointInput = "http://gpu.lan:8000", + ApiKeyInput = "sk-gateway-key", + }; + + step.WriteProviderCredentials(_paths); + + var entry = ReadProviderEntry("openai-compatible"); + Assert.Equal("ApiKey", entry.GetProperty("AuthMethod").GetString()); + + var secrets = File.ReadAllText(_paths.SecretsPath); + Assert.Contains("ENC:", secrets); + } + + // ── Headless typed-key end-to-end (automation floor) ── + + [Fact] + public async Task ManagerAddFlow_OpenAiCompatibleApiKey_TypedKeyEndToEnd() + { + var (terminal, app, vm, input) = CreateHeadlessApp(); + + using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var run = app.RunAsync(appCts.Token); + + try + { + await WaitForAsync(() => terminal.Contains("Provider Manager"), appCts.Token); + + foreach (var _ in _registry.KnownTypeKeys.TakeWhile(t => t != "openai-compatible")) + input.EnqueueKey(ConsoleKey.DownArrow); + + // type row -> name step; accept generated name. + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Enter); + // auth picker: "No auth (local endpoint)" is first, "API Key" second. + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + // endpoint stage. + input.EnqueuePaste("http://gpu.lan:8000"); + input.EnqueueKey(ConsoleKey.Enter); + // key stage. + input.EnqueuePaste("sk-gateway-key"); + input.EnqueueKey(ConsoleKey.Enter); + + await WaitForAsync(() => vm.CurrentState.Value == ProviderManagerState.AddComplete, appCts.Token); + + Assert.Equal("openai-compatible", vm.NewProviderType); + Assert.Equal("http://gpu.lan:8000", vm.NewEndpoint); + Assert.Equal("sk-gateway-key", vm.NewApiKey); + Assert.True(File.Exists(_paths.NetclawConfigPath)); + + var entry = ReadProviderEntry("my-openai-compatible"); + Assert.Equal("ApiKey", entry.GetProperty("AuthMethod").GetString()); + } + finally + { + input.EnqueueKey(ConsoleKey.Q, control: true); + await run.WaitAsync(appCts.Token); + } + } + + // ── Helpers ── + + private ProviderManagerViewModel CreateManagerVm() + { + var vm = new ProviderManagerViewModel(_paths, _registry, _fakeProbe); + vm.RefreshDisplayProviders(); + return vm; + } + + private JsonElement ReadConfig() + { + Assert.True(File.Exists(_paths.NetclawConfigPath)); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + return doc.RootElement.Clone(); + } + + private JsonElement ReadProviderEntry(string name) + => ReadConfig().GetProperty("Providers").GetProperty(name); + + private void WriteConfigProvider(string name, string type, string? authMethod) + { + var entry = new Dictionary { ["Type"] = type }; + if (authMethod is not null) + entry["AuthMethod"] = authMethod; + entry["Endpoint"] = "http://gpu.lan:8000"; + + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary { [name] = entry } + }); + } + + private void WriteConfig(Dictionary data) + { + File.WriteAllText(_paths.NetclawConfigPath, + JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + } + + private (VirtualTerminal Terminal, TerminaApplication App, ProviderManagerViewModel Vm, VirtualInputSource Input) + CreateHeadlessApp() + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + ProviderManagerViewModel? capturedVm = null; + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/provider", builder => + { + builder.RegisterRoute( + "/provider", + _ => new ProviderManagerPage(), + _ => + { + capturedVm = new ProviderManagerViewModel(_paths, _registry, _fakeProbe); + return capturedVm; + }); + }); + + var sp = services.BuildServiceProvider(); + return (terminal, sp.GetRequiredService(), capturedVm!, virtualInput); + } + + private static async Task WaitForAsync(Func predicate, CancellationToken ct) + { + while (!predicate()) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } +} diff --git a/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs b/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs index 492ed76c6..d7cb07ae9 100644 --- a/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs @@ -171,7 +171,15 @@ private static string BuildNoProviderRemediation(IReadOnlyList available { var supported = descriptor.Auth.SupportedAuthMethods; if (supported.Contains(AuthMethod.None)) - return null; + { + // Optional-auth provider (e.g. openai-compatible): "No auth" is complete + // by definition, but an entry that explicitly declares ApiKey without a + // stored key is a misconfiguration — the transport would silently send + // unauthenticated requests to an endpoint the operator said needs a key. + return provider.AuthMethod == AuthMethod.ApiKey && provider.ApiKey.IsNullOrEmpty() + ? $"provider '{providerName}' ({descriptor.TypeKey}) declares AuthMethod ApiKey but has no ApiKey in secrets.json." + : null; + } var hasApiKey = !provider.ApiKey.IsNullOrEmpty(); var hasOAuthToken = !provider.OAuthAccessToken.IsNullOrEmpty(); diff --git a/src/Netclaw.Cli/Provider/ProviderCommand.cs b/src/Netclaw.Cli/Provider/ProviderCommand.cs index 2f30921fb..99d0a5446 100644 --- a/src/Netclaw.Cli/Provider/ProviderCommand.cs +++ b/src/Netclaw.Cli/Provider/ProviderCommand.cs @@ -576,6 +576,13 @@ private static void WriteProviderGuidance(IProviderDescriptor descriptor, TextWr return; } + if (descriptor.Auth is EndpointOrApiKeyAuth) + { + writer.WriteLine($"{descriptor.DisplayName} takes an endpoint and an optional API key (sent as a Bearer token)."); + writer.WriteLine("Pass --api-key only if your endpoint requires authentication."); + return; + } + if (descriptor.Auth is OAuthAuth) { writer.WriteLine($"{descriptor.DisplayName} uses OAuth. Run `netclaw provider` to authenticate."); diff --git a/src/Netclaw.Cli/Tui/OAuthFlowViews.cs b/src/Netclaw.Cli/Tui/OAuthFlowViews.cs index 5f34761a6..7560b8931 100644 --- a/src/Netclaw.Cli/Tui/OAuthFlowViews.cs +++ b/src/Netclaw.Cli/Tui/OAuthFlowViews.cs @@ -22,16 +22,21 @@ internal static class OAuthFlowViews /// /// Map auth methods to user-friendly display labels for selection lists. /// Uses custom per-provider labels from when available. + /// is included only when the provider offers + /// it alongside other methods (e.g. optional-key OpenAI-compatible endpoints); + /// single-method None providers never render a picker at all. /// public static List BuildAuthMethodLabels(IProviderAuth auth) { var customLabels = (auth as MultiAuth)?.AuthMethodLabels; + var includeNone = auth.SupportedAuthMethods.Count > 1; return [.. auth.SupportedAuthMethods - .Where(m => m != AuthMethod.None) + .Where(m => includeNone || m != AuthMethod.None) .Select(m => customLabels?.TryGetValue(m, out var label) == true ? label : m switch { + AuthMethod.None => "No auth (local endpoint)", AuthMethod.ApiKey => "API Key", AuthMethod.OAuthPkce => "OAuth Login (recommended)", AuthMethod.OAuthDevice => "OAuth Device Flow", @@ -56,6 +61,7 @@ public static AuthMethod ParseAuthMethodLabel(string label, IProviderAuth? auth return label switch { + "No auth (local endpoint)" => AuthMethod.None, "API Key" => AuthMethod.ApiKey, "OAuth Login (recommended)" => AuthMethod.OAuthPkce, "OAuth Device Flow" => AuthMethod.OAuthDevice, diff --git a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs index 03041b5be..0216b3707 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs @@ -92,6 +92,7 @@ private LayoutNode BuildContent() ProviderManagerState.AddGitHubCopilotEnterpriseHost => BuildGitHubCopilotEnterpriseHostView(), ProviderManagerState.AddGitHubCopilotEnterpriseApiBase => BuildGitHubCopilotEnterpriseApiBaseView(), ProviderManagerState.AddCredentials => BuildCredentialsView(), + ProviderManagerState.AddCredentialsEndpoint => BuildCredentialsEndpointView(), ProviderManagerState.AddOAuthDeviceFlow => BuildOAuthDeviceFlowView(), ProviderManagerState.AddBrowserOAuthFlow => BuildBrowserOAuthFlowView(), ProviderManagerState.AddValidating => BuildValidatingView(), @@ -99,6 +100,7 @@ private LayoutNode BuildContent() ProviderManagerState.Details => BuildDetailsView(), ProviderManagerState.RenameProvider => BuildRenameView(), ProviderManagerState.FixCredentials => BuildFixCredentialsView(), + ProviderManagerState.FixApiKey => BuildFixApiKeyView(), ProviderManagerState.RemoveConfirm => BuildRemoveConfirmView(), _ => Layouts.Empty() }; @@ -559,6 +561,91 @@ private ILayoutNode BuildCredentialsView() children.WithChild(new TextNode($" {descriptor.DisplayName} runs locally. No authentication required.") .WithForeground(Color.Gray)); } + else if (descriptor.Auth is EndpointOrApiKeyAuth) + { + // Optional-auth endpoint. "No auth" entered the endpoint here directly; + // "API Key" renders the key input (endpoint was collected one stage earlier). + children.WithChild(new TextNode("").Height(1)); + + if (ViewModel.NewAuthMethod == AuthMethod.None) + { + children.WithChild(new TextNode($" Endpoint (default: {descriptor.DefaultEndpoint}):") + .WithForeground(Color.White)); + + _endpointInput = new TextInputNode() + .WithPlaceholder(descriptor.DefaultEndpoint); + _endpointInput.OnFocused(); + _lastFocusedInput = _endpointInput; + + _endpointInput.Submitted + .Subscribe(text => + { + ViewModel.NewEndpoint = string.IsNullOrWhiteSpace(text) ? null : text; + ViewModel.SubmitCredentials(); + }) + .DisposeWith(_stepSubs); + + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_endpointInput, "Endpoint")); + + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode(" No auth selected. Your endpoint must be reachable without a key.") + .WithForeground(Color.Gray)); + } + else + { + children.WithChild(new TextNode(" API Key:").WithForeground(Color.White)); + + _apiKeyInput = new TextInputNode() + .AsPassword() + .WithPlaceholder($"Enter {providerType} API key..."); + _apiKeyInput.OnFocused(); + _lastFocusedInput = _apiKeyInput; + + _apiKeyInput.Submitted + .Subscribe(text => + { + ViewModel.NewApiKey = text; + ViewModel.SubmitCredentials(); + }) + .DisposeWith(_stepSubs); + + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_apiKeyInput, "API Key")); + + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode(" The key is sent as a Bearer token and stored in secrets.json.") + .WithForeground(Color.Gray)); + } + } + + return children; + } + + private ILayoutNode BuildCredentialsEndpointView() + { + var children = Layouts.Vertical(); + var providerType = ViewModel.NewProviderType ?? "unknown"; + var descriptor = ViewModel.Registry.Get(providerType); + + children.WithChild(new TextNode($" Provider: {descriptor.DisplayName} (name: {ViewModel.NewProviderName})") + .WithForeground(Color.White)); + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode($" Endpoint (default: {descriptor.DefaultEndpoint}):") + .WithForeground(Color.White)); + + _endpointInput = new TextInputNode() + .WithPlaceholder(descriptor.DefaultEndpoint); + _endpointInput.OnFocused(); + _lastFocusedInput = _endpointInput; + + _endpointInput.Submitted + .Subscribe(text => ViewModel.SubmitEndpointCredential(text)) + .DisposeWith(_stepSubs); + + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_endpointInput, "Endpoint")); + + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode(" Next: enter the API key sent as a Bearer token to this endpoint.") + .WithForeground(Color.Gray)); return children; } @@ -834,7 +921,7 @@ private ILayoutNode BuildFixCredentialsView() children.WithChild(new TextNode("").Height(1)); children.WithChild(reAuthList); } - else if (descriptor.Auth is EndpointOnlyAuth) + else if (descriptor.Auth is EndpointOnlyAuth or EndpointOrApiKeyAuth) { children.WithChild(new TextNode("").Height(1)); children.WithChild(new TextNode(" Endpoint:").WithForeground(Color.White)); @@ -845,16 +932,17 @@ private ILayoutNode BuildFixCredentialsView() _lastFocusedInput = _endpointInput; _endpointInput.Submitted - .Subscribe(text => - { - ViewModel.FixEndpoint = string.IsNullOrWhiteSpace(text) - ? item.Entry?.Endpoint - : text; - ViewModel.SubmitFixCredentials(); - }) + .Subscribe(text => ViewModel.SubmitFixEndpoint(text)) .DisposeWith(_stepSubs); children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_endpointInput, "Endpoint")); + + if (descriptor.Auth is EndpointOrApiKeyAuth && item.Entry?.AuthMethod == AuthMethod.ApiKey) + { + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode(" Next: enter the replacement API key for this endpoint.") + .WithForeground(Color.Gray)); + } } else { @@ -889,6 +977,36 @@ private ILayoutNode BuildFixCredentialsView() return children; } + private ILayoutNode BuildFixApiKeyView() + { + var item = ViewModel.DetailProvider; + if (item is null) + return Layouts.Empty(); + + var children = Layouts.Vertical(); + children.WithChild(new TextNode($" New API key for: {item.ConfiguredName} ({item.DisplayName})") + .WithForeground(Color.White).Bold()); + children.WithChild(new TextNode("").Height(1)); + + _apiKeyInput = new TextInputNode() + .AsPassword() + .WithPlaceholder($"Enter new {item.DisplayName} API key..."); + _apiKeyInput.OnFocused(); + _lastFocusedInput = _apiKeyInput; + + _apiKeyInput.Submitted + .Subscribe(text => + { + ViewModel.FixApiKey = text; + ViewModel.SubmitFixCredentials(); + }) + .DisposeWith(_stepSubs); + + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_apiKeyInput, "API Key")); + + return children; + } + private ILayoutNode BuildRemoveConfirmView() { if (ViewModel.RemoveBlockingRoles.Count > 0) diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs index 8dfa5cb45..8da492ca8 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs @@ -41,7 +41,9 @@ public enum ProviderManagerState Details, RenameProvider, FixCredentials, - RemoveConfirm + FixApiKey, + RemoveConfirm, + AddCredentialsEndpoint } /// @@ -513,6 +515,15 @@ public void SelectAuthMethod(AuthMethod method) return; } + // Optional-auth endpoint with API key selected: endpoint first, then the key. + if (method == AuthMethod.ApiKey + && _registry.Get(NewProviderType!).Auth is EndpointOrApiKeyAuth) + { + CurrentState.Value = ProviderManagerState.AddCredentialsEndpoint; + NotifyStateChanged(); + return; + } + CurrentState.Value = ProviderManagerState.AddCredentials; NotifyStateChanged(); } @@ -652,6 +663,39 @@ public void SubmitCredentials() StartProbe(); } + /// + /// Submit the endpoint stage of an optional-auth add flow (API key method + /// selected) and advance to the API key input. + /// + public void SubmitEndpointCredential(string? endpoint) + { + NewEndpoint = string.IsNullOrWhiteSpace(endpoint) ? null : endpoint; + CurrentState.Value = ProviderManagerState.AddCredentials; + NotifyStateChanged(); + } + + /// + /// Submit the endpoint stage of a fix-credentials flow. When the provider's + /// stored entry declares API key auth, advance to the key stage; otherwise + /// submit the fix for probing directly. + /// + public void SubmitFixEndpoint(string? endpoint) + { + FixEndpoint = string.IsNullOrWhiteSpace(endpoint) + ? DetailProvider?.Entry?.Endpoint + : endpoint; + + if (DetailProvider?.Entry?.AuthMethod == AuthMethod.ApiKey) + { + FixApiKey = null; + CurrentState.Value = ProviderManagerState.FixApiKey; + NotifyStateChanged(); + return; + } + + SubmitFixCredentials(); + } + /// /// Submit fixed credentials and start validation probe. /// @@ -662,7 +706,12 @@ public void SubmitFixCredentials() var type = DetailProvider.ProviderType; var descriptor = _registry.Get(type); - if (descriptor.Auth.SupportedAuthMethods.Contains(AuthMethod.ApiKey) && string.IsNullOrWhiteSpace(FixApiKey)) + // Key required when the provider type only offers API key auth, or when + // this entry itself declares API key auth. Optional-auth providers + // (openai-compatible) configured with No auth fix their endpoint only. + var keyRequired = descriptor.Auth.SupportedAuthMethods is [AuthMethod.ApiKey] + || DetailProvider.Entry?.AuthMethod == AuthMethod.ApiKey; + if (keyRequired && string.IsNullOrWhiteSpace(FixApiKey)) { StatusMessage.Value = "API key is required."; RequestRedraw(); @@ -1016,12 +1065,22 @@ public void GoBack() break; case ProviderManagerState.AddCredentials: var descriptor = _registry.Get(NewProviderType ?? ""); - if (descriptor.Auth.SupportedAuthMethods is [AuthMethod.None]) + if (descriptor.Auth is EndpointOrApiKeyAuth && NewAuthMethod == AuthMethod.ApiKey) + CurrentState.Value = ProviderManagerState.AddCredentialsEndpoint; + else if (descriptor.Auth.SupportedAuthMethods is [AuthMethod.None]) GoBackToList(); else CurrentState.Value = ProviderManagerState.AddSelectAuth; NotifyStateChanged(); break; + case ProviderManagerState.AddCredentialsEndpoint: + CurrentState.Value = ProviderManagerState.AddSelectAuth; + NotifyStateChanged(); + break; + case ProviderManagerState.FixApiKey: + CurrentState.Value = ProviderManagerState.FixCredentials; + NotifyStateChanged(); + break; case ProviderManagerState.AddValidating: CancelProbe(); if (IsFixFlow) diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepView.cs index 06c9c7414..cbdc40a9e 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepView.cs @@ -67,6 +67,7 @@ public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks c 7 => BuildGitHubCopilotAuthHost(vm, callbacks), 8 => BuildGitHubCopilotEnterpriseHost(vm, callbacks), 9 => BuildGitHubCopilotEnterpriseApiBase(vm, callbacks), + 10 => BuildApiKeyInput(vm, callbacks), _ => Layouts.Empty() }; } @@ -274,7 +275,8 @@ private ILayoutNode BuildCredentialInput(ProviderStepViewModel vm, StepViewCallb _lastFocusedList = null; - if (descriptor.Auth is EndpointOnlyAuth) + if (descriptor.Auth is EndpointOnlyAuth + || (descriptor.Auth is EndpointOrApiKeyAuth && vm.SelectedAuthMethod == AuthMethod.None)) { var defaultEndpoint = descriptor.DefaultEndpoint; _endpointInput = new TextInputNode().WithPlaceholder(defaultEndpoint); @@ -292,11 +294,51 @@ private ILayoutNode BuildCredentialInput(ProviderStepViewModel vm, StepViewCallb }) .DisposeWith(callbacks.Subscriptions); + var hint = descriptor.Auth is EndpointOrApiKeyAuth + ? new TextNode(" No auth selected. Back up and choose API Key if your endpoint requires a key.") + .WithForeground(Color.Gray) + : new TextNode("").Height(1); + + return Layouts.Vertical() + .WithChild(new TextNode($" {displayName} endpoint:").WithForeground(Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(_endpointInput, "Endpoint")) + .WithChild(hint); + } + + if (descriptor.Auth is EndpointOrApiKeyAuth) + { + // API key selected for an optional-auth endpoint: endpoint first, then the key. + var defaultEndpoint = descriptor.DefaultEndpoint; + _endpointInput = new TextInputNode().WithPlaceholder(defaultEndpoint); + _endpointInput.Text = vm.EndpointInput ?? defaultEndpoint; + _endpointInput.OnFocused(); + _lastFocusedInput = _endpointInput; + + _endpointInput.Submitted + .Subscribe(text => + { + vm.EndpointInput = string.IsNullOrWhiteSpace(text) ? defaultEndpoint : text; + vm.SetSubStep(10); + callbacks.InvalidateAndRedraw(); + }) + .DisposeWith(callbacks.Subscriptions); + return Layouts.Vertical() .WithChild(new TextNode($" {displayName} endpoint:").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_endpointInput, "Endpoint")); } + return BuildApiKeyInput(vm, callbacks); + } + + private ILayoutNode BuildApiKeyInput(ProviderStepViewModel vm, StepViewCallbacks callbacks) + { + var providerType = vm.SelectedProviderType ?? "unknown"; + var descriptor = vm.Registry.Get(providerType); + var displayName = descriptor.DisplayName; + + _lastFocusedList = null; + _apiKeyInput = new TextInputNode() .AsPassword() .WithPlaceholder($"Enter {displayName} API key..."); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index 6af926599..340ecbc08 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs @@ -24,7 +24,8 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Wizard step for selecting and configuring the LLM provider. /// Sub-steps: 0=provider selection, 1=auth method, 2=credentials, 3=validation, /// 4=model selection, 5=OAuth device flow, 6=OAuth browser flow, -/// 7=GitHub Copilot host mode, 8=GitHub Enterprise host, 9=GitHub Enterprise API base. +/// 7=GitHub Copilot host mode, 8=GitHub Enterprise host, 9=GitHub Enterprise API base, +/// 10=API key entry after endpoint (optional-auth OpenAI-compatible). /// public sealed class ProviderStepViewModel : IWizardStepViewModel, ISectionEditor { @@ -95,6 +96,7 @@ public ProviderStepViewModel( 7 => " Choose whether GitHub Copilot should authenticate through GitHub.com or GitHub Enterprise.", 8 => " Enter the GitHub Enterprise web host used for OAuth.", 9 => " Enter the GitHub Enterprise API base, or leave blank to use the derived default.", + 10 => " Enter the API key for your endpoint. It will be stored in secrets.json.", _ => "" }; @@ -144,6 +146,9 @@ public bool TryGoBack() case 9: // GitHub Enterprise API base → GitHub Enterprise host _currentSubStep = 8; return true; + case 10: // Optional API key after endpoint → endpoint input + _currentSubStep = 2; + return true; case 4: // Model selection → credentials _currentSubStep = SelectedAuthMethod switch { @@ -449,7 +454,7 @@ public void ContributeConfig(WizardConfigBuilder builder) AuthMethod = SelectedAuthMethod, Endpoint = !string.IsNullOrWhiteSpace(EndpointInput) ? EndpointInput - : _registry.TryGet(providerName, out var desc) && desc.Auth is EndpointOnlyAuth + : _registry.TryGet(providerName, out var desc) && desc.Auth is EndpointOnlyAuth or EndpointOrApiKeyAuth ? desc.DefaultEndpoint : null, VendorOptions = VendorOptions, @@ -657,7 +662,7 @@ private Dictionary BuildProviderEntry(ProviderStepViewModel vm, var endpoint = !string.IsNullOrWhiteSpace(vm.EndpointInput) ? vm.EndpointInput - : _registry.TryGet(providerType, out var descriptor) && descriptor.Auth is EndpointOnlyAuth + : _registry.TryGet(providerType, out var descriptor) && descriptor.Auth is EndpointOnlyAuth or EndpointOrApiKeyAuth ? descriptor.DefaultEndpoint : null; diff --git a/src/Netclaw.Providers/IProviderAuth.cs b/src/Netclaw.Providers/IProviderAuth.cs index 37dadd993..9eacb47f2 100644 --- a/src/Netclaw.Providers/IProviderAuth.cs +++ b/src/Netclaw.Providers/IProviderAuth.cs @@ -33,13 +33,25 @@ public sealed class ApiKeyAuth : IProviderAuth } /// -/// Provider requires no authentication — just an endpoint (Ollama, OpenAI-compatible). +/// Provider requires no authentication — just an endpoint (Ollama). /// public sealed class EndpointOnlyAuth : IProviderAuth { public IReadOnlyList SupportedAuthMethods { get; } = [AuthMethod.None]; } +/// +/// Provider takes an endpoint where auth is optional — an API key may be +/// supplied as a Bearer token (OpenAI-compatible backends behind a gateway). +/// is first so local no-auth endpoints remain +/// the default selection in the TUI. +/// +public sealed class EndpointOrApiKeyAuth : IProviderAuth +{ + public IReadOnlyList SupportedAuthMethods { get; } = + [AuthMethod.None, AuthMethod.ApiKey]; +} + /// /// Provider authenticates via OAuth (device flow, browser PKCE, or both). /// diff --git a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs index dbb976ae2..a37db04cc 100644 --- a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs +++ b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs @@ -26,7 +26,7 @@ public OpenAiCompatibleDescriptor(HttpClient httpClient) public string DisplayName => "OpenAI-compatible (llama.cpp / vLLM / DwarfStar ds4)"; public string DefaultEndpoint => "http://localhost:11434"; public string ModelListingPath => "/v1/models"; - public IProviderAuth Auth { get; } = new EndpointOnlyAuth(); + public IProviderAuth Auth { get; } = new EndpointOrApiKeyAuth(); public Task ProbeAsync( ProviderEntry entry, CancellationToken ct = default) From 989084c1de1bc1e725dcb540eeb9e34c68075389 Mon Sep 17 00:00:00 2001 From: Moaaz Tarek Date: Wed, 19 Aug 2026 13:05:07 +0300 Subject: [PATCH 3/3] feat(provider): enhance model listing path resolution for OpenAI-compatible provider --- .../SelfHosted/OpenAiCompatibleDescriptor.cs | 10 +++++++- .../SelfHosted/OpenAiCompatibleEndpoint.cs | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs index a37db04cc..8ee6789e5 100644 --- a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs +++ b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleDescriptor.cs @@ -25,17 +25,25 @@ public OpenAiCompatibleDescriptor(HttpClient httpClient) public string TypeKey => "openai-compatible"; public string DisplayName => "OpenAI-compatible (llama.cpp / vLLM / DwarfStar ds4)"; public string DefaultEndpoint => "http://localhost:11434"; + // Unversioned default. The probe resolves the effective path per + // endpoint via OpenAiCompatibleEndpoint.RelativeModelsPath because a + // base that already pins a version (…/v4) must not get another "/v1". public string ModelListingPath => "/v1/models"; public IProviderAuth Auth { get; } = new EndpointOrApiKeyAuth(); public Task ProbeAsync( ProviderEntry entry, CancellationToken ct = default) { + var effectiveBase = string.IsNullOrWhiteSpace(entry.Endpoint) + ? DefaultEndpoint + : entry.Endpoint; + var listingPath = OpenAiCompatibleEndpoint.RelativeModelsPath(effectiveBase); + return ProbeHelpers.ExecuteProbeAsync( _httpClient, TypeKey, DefaultEndpoint, - ModelListingPath, + listingPath, entry.Endpoint, request => { diff --git a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs index 5eec87384..74f8f2a88 100644 --- a/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs +++ b/src/Netclaw.Providers/SelfHosted/OpenAiCompatibleEndpoint.cs @@ -36,6 +36,29 @@ public static OpenAiCompatibleEndpoint FromBaseUrl(string endpoint, string? apiK ApiKey: apiKey); } + /// + /// Relative model-listing path for a base URL: "/models" when the base + /// already pins a version segment (…/v4), "/v1/models" otherwise. Probe + /// callers string-concatenate this onto the base (no separator is + /// inserted), so it MUST start with "/". It must stay in sync with + /// , which bakes the same version-suffix rule + /// into the runtime . + /// + public static string RelativeModelsPath(string endpoint) + { + try + { + var basePath = new Uri(endpoint.TrimEnd('/')).AbsolutePath.TrimEnd('/'); + return HasVersionedSuffix(basePath) ? "/models" : "/v1/models"; + } + catch (UriFormatException) + { + // Malformed base: keep the unversioned default and let the HTTP + // layer surface the connection failure through its error path. + return "/v1/models"; + } + } + private static bool HasVersionedSuffix(string basePath) { var lastSlash = basePath.LastIndexOf('/');