Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireHostingVersion)" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="$(AspireHostingVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.Hosting.Ollama" Version="$(CommunityToolkitAspireVersion)" />
<!-- Transitive security pin: Aspire 13.4.x pulls MessagePack 2.5.192, which
trips NU1903 (GHSA-hv8m-jj95-wg3x, LZ4 decompression AccessViolation).
Keep this on the patched 3.x line already used by dev until Aspire ships
a non-vulnerable transitive dependency on its own. -->
<PackageVersion Include="MessagePack" Version="3.1.7" />
</ItemGroup>
<!-- Image normalization (Netclaw.Media). SkiaSharp ships Windows/macOS native
assets in the base package; Linux needs the explicit headless native asset
Expand All @@ -106,13 +111,7 @@
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.300" />
</ItemGroup>
<!-- Transitive security pin: Aspire.Hosting.AppHost → StreamJsonRpc pulls in
MessagePack 2.5.192, flagged by NuGetAudit (GHSA-hv8m-jj95-wg3x, LZ4
decompression DoS). Netclaw uses MessagePack nowhere — this is sample-only
Aspire tooling RPC — but pin to the patched v2 (2.5.301) to clear the audit. -->
<ItemGroup>
<PackageVersion Include="MessagePack" Version="3.1.7" />
</ItemGroup>

<!-- Transitive audit suppress: Microsoft.Data.Sqlite → SQLitePCLRaw.lib.e_sqlite3 2.1.11
is flagged by NuGetAudit (GHSA-2m69-gcr7-jv3q, CVE-2025-6965 — SQLite < 3.50.2
memory corruption in aggregate-term handling). No patched version of
Expand All @@ -123,4 +122,4 @@
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-2m69-gcr7-jv3q" />
</ItemGroup>
</Project>
</Project>
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ dotnet run --project samples/Netclaw.Demo.AppHost
### Prerequisites

- An LLM provider — [Ollama](https://ollama.com/) (local, default),
[OpenRouter](https://openrouter.ai/), or any OpenAI-compatible endpoint.
[OpenRouter](https://openrouter.ai/), [DwarfStar/ds4](https://github.com/antirez/ds4)
(local DeepSeek V4 on Apple Silicon / CUDA), or any OpenAI-compatible endpoint.
See the full [provider documentation](https://netclaw.dev/configuration/managed-providers/)
for all supported options.

Expand Down
3 changes: 3 additions & 0 deletions docs/spec/SPEC-008-model-provider-abstraction.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Session actors depend on provider-neutral chat client behavior.
- Anthropic direct
- OpenAI direct
- Ollama via OpenAI-compatible local endpoint
- DwarfStar (ds4) — local DeepSeek V4 engine via the `openai-compatible`
provider type; its OpenRouter-shaped `context_length` metadata is read by a
dedicated backend strategy in the capability resolver

## Configuration Model

Expand Down
6 changes: 5 additions & 1 deletion docs/spec/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ keys used by model references.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `Type` | string | `"ollama"` | Provider SDK to use. Currently supported: `ollama`. Future: `openrouter`, `openai`, `anthropic`. |
| `Type` | string | `"ollama"` | Provider SDK to use. Supported: `ollama`, `openai-compatible`, `openrouter`, `openai`, `anthropic`, `github-copilot`, `veniceai`. |
| `Endpoint` | string | `"http://localhost:11434"` | Base URL for the provider API. |
| `ApiKey` | string? | `null` | API key. Should go in `secrets.json` or an environment variable. |

Expand Down Expand Up @@ -512,6 +512,10 @@ export NETCLAW_Session__MaxToolIterationsPerTurn="60"
"openrouter": {
"Type": "openrouter",
"Endpoint": "https://openrouter.ai/api/v1"
},
"ds4": {
"Type": "openai-compatible",
"Endpoint": "http://127.0.0.1:8000"
}
},
"Models": {
Expand Down
4 changes: 2 additions & 2 deletions feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
version: "2.16.0"
version: "2.17.0"
---

# Netclaw Operations
Expand Down Expand Up @@ -803,7 +803,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` | API key + endpoint | Generic OpenAI-shape proxies |
| `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 |
| `anthropic` | API key | `sk-ant-...` |
| `openrouter` | API key | `sk-or-...` |
| `github-copilot` | OAuth device flow only | Requires active Copilot subscription on the GitHub account |
Expand Down
20 changes: 20 additions & 0 deletions openspec/specs/netclaw-model-capabilities/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ it, as the highest-priority detection source.
- **AND** map array values (`"text"`, `"image"`, `"audio"`, `"video"`) to
corresponding `ModelModality` flags

#### Scenario: OpenAI-compatible backend strategy detection

- **GIVEN** a model is configured on an `openai-compatible` provider
- **WHEN** capabilities are resolved for that model
- **THEN** the system SHALL probe `/v1/models` and the optional llama.cpp
`/props` endpoint
- **AND** dispatch the probe to the first matching backend strategy in priority
order: DwarfStar/ds4, vLLM, llama.cpp, generic OpenAI-compatible

#### Scenario: DwarfStar ds4 context detection

- **GIVEN** an OpenAI-compatible `/v1/models` entry for the configured model
has `owned_by: "ds4.c"`
- **WHEN** capabilities are resolved for that model
- **THEN** the DwarfStar/ds4 strategy SHALL match before broader backend
heuristics
- **AND** context window SHALL be read from top-level `context_length`, falling
back to `top_provider.context_length`
- **AND** input and output modalities SHALL resolve to `Text`

### Requirement: OpenRouter oracle for cross-provider lookup

The system SHALL use OpenRouter's public `GET /api/v1/models` endpoint as a
Expand Down
16 changes: 14 additions & 2 deletions openspec/specs/netclaw-model-providers/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ The system SHALL default to OpenRouter during first-run setup.
### 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.
Expand All @@ -27,7 +29,8 @@ and `OutputModalities` fields populated from provider responses.
#### Scenario: Switch provider

- **GIVEN** OpenRouter is configured
- **WHEN** operator selects Anthropic, OpenAI, or Ollama profile
- **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

Expand All @@ -54,6 +57,16 @@ and `OutputModalities` fields populated from provider responses.
`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`

### Requirement: Optional live smoke provider checks

The system SHALL support optional provider smoke checks against a local
Expand Down Expand Up @@ -318,4 +331,3 @@ by Copilot with `HTTP 400 "Authorization header is badly formatted"`.
- **THEN** the SDK's credential auth policy emits the exchanged Copilot token,
not the placeholder, because the shared credential was updated before the auth
policy ran

2 changes: 1 addition & 1 deletion src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ public void Refresh_PopulatesDisplayNameFromRegistry()

Assert.Single(vm.Providers);
Assert.Equal("my-vllm", vm.Providers[0].Name);
Assert.Equal("llama.cpp / vLLM", vm.Providers[0].DisplayName);
Assert.Equal("OpenAI-compatible (llama.cpp / vLLM / DwarfStar ds4)", vm.Providers[0].DisplayName);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public sealed class OpenAiCompatibleCapabilityResolverTests
[Theory]
[InlineData("{\"id\":\"Qwen3.6-27B-MTP-UD-Q4_K_XL.gguf\",\"meta\":{\"n_ctx\":131072,\"n_ctx_train\":262144}}", 131_072)]
[InlineData("{\"id\":\"Qwen/Qwen3.6-VL-30B-FP8\",\"max_model_len\":256000}", 256_000)]
[InlineData("{\"id\":\"deepseek-v4-flash\",\"owned_by\":\"ds4.c\",\"context_length\":262144}", 262_144)]
[InlineData("{\"id\":\"deepseek-v4-pro\",\"owned_by\":\"ds4.c\",\"top_provider\":{\"context_length\":196608}}", 196_608)]
public void ParseModels_ReadsSelfHostedContextMetadata(string modelJson, int expectedContextWindow)
{
var json = $$"""
Expand Down Expand Up @@ -168,6 +170,65 @@ public void ResolveFromProbe_LlamaCppRouterPropsZero_ReturnsUnknownContext()
Assert.Null(result.OutputModalities);
}

[Fact]
public void ResolveFromProbe_Ds4Shape_DispatchesToDs4Strategy_EvenWithProps()
{
const string modelsJson = """
{
"object": "list",
"data": [
{
"id": "deepseek-v4-flash",
"object": "model",
"owned_by": "ds4.c",
"context_length": 262144,
"top_provider": { "context_length": 262144 }
}
]
}
""";
// A proxy in front of ds4 may answer /props; the ds4 strategy is
// ordered before llama.cpp so the owned_by signal still wins.
const string propsJson = """
{ "default_generation_settings": { "n_ctx": 4096 } }
""";

var result = OpenAiCompatibleCapabilityResolver.ResolveFromProbe(
"deepseek-v4-flash", modelsJson, propsJson);

Assert.NotNull(result);
Assert.Equal(262_144, result.ContextWindowTokens);
Assert.Equal(ModelModality.Text, result.InputModalities);
Assert.Equal(ModelModality.Text, result.OutputModalities);
}

[Fact]
public void ResolveFromProbe_Ds4Shape_DispatchesToDs4Strategy_EvenWithMaxModelLen()
{
const string modelsJson = """
{
"object": "list",
"data": [
{
"id": "deepseek-v4-flash",
"object": "model",
"owned_by": "ds4.c",
"max_model_len": 4096,
"context_length": 262144
}
]
}
""";

var result = OpenAiCompatibleCapabilityResolver.ResolveFromProbe(
"deepseek-v4-flash", modelsJson, propsJson: null);

Assert.NotNull(result);
Assert.Equal(262_144, result.ContextWindowTokens);
Assert.Equal(ModelModality.Text, result.InputModalities);
Assert.Equal(ModelModality.Text, result.OutputModalities);
}

[Fact]
public void ResolveFromProbe_UnknownShape_FallsThroughToGenericStrategy()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------
// <copyright file="Ds4BackendStrategyTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using System.Text.Json;
using Netclaw.Configuration;
using Netclaw.Providers.SelfHosted;
using Xunit;

namespace Netclaw.Daemon.Tests.Providers.Strategies;

public sealed class Ds4BackendStrategyTests
{
// Real ds4-server response shape (see append_model_json_values in
// ds4_server.c): OpenRouter-shaped metadata with owned_by "ds4.c".
private const string Ds4ModelsJson = """
{
"object": "list",
"data": [
{
"id": "deepseek-v4-flash",
"object": "model",
"created": 1767225600,
"owned_by": "ds4.c",
"name": "DeepSeek V4 Flash",
"context_length": 262144,
"top_provider": {
"context_length": 262144,
"max_completion_tokens": 8192,
"is_moderated": false
},
"supported_parameters": ["tools", "tool_choice", "stream"]
},
{
"id": "deepseek-v4-pro",
"object": "model",
"created": 1767225600,
"owned_by": "ds4.c",
"name": "DeepSeek V4 PRO",
"top_provider": { "context_length": 196608 }
}
]
}
""";

[Fact]
public void Matches_OwnedByDs4()
{
using var doc = JsonDocument.Parse(Ds4ModelsJson);
var probe = new BackendProbe("deepseek-v4-flash", doc.RootElement, PropsRoot: null);
Assert.True(new Ds4BackendStrategy().Matches(probe));
}

[Fact]
public void Matches_False_WhenOwnedByOther()
{
const string json = """
{ "object": "list", "data": [ { "id": "model-x", "owned_by": "vllm", "context_length": 131072 } ] }
""";
using var doc = JsonDocument.Parse(json);
var probe = new BackendProbe("model-x", doc.RootElement, PropsRoot: null);
Assert.False(new Ds4BackendStrategy().Matches(probe));
}

[Fact]
public void Matches_False_WhenModelMissing()
{
using var doc = JsonDocument.Parse(Ds4ModelsJson);
var probe = new BackendProbe("gpt-4", doc.RootElement, PropsRoot: null);
Assert.False(new Ds4BackendStrategy().Matches(probe));
}

[Fact]
public void Parse_ReadsTopLevelContextLength_AndTextModalities()
{
using var doc = JsonDocument.Parse(Ds4ModelsJson);
var probe = new BackendProbe("deepseek-v4-flash", doc.RootElement, PropsRoot: null);

var result = new Ds4BackendStrategy().Parse(probe);

Assert.NotNull(result);
Assert.Equal(262_144, result.ContextWindowTokens);
Assert.Equal(ModelModality.Text, result.InputModalities);
Assert.Equal(ModelModality.Text, result.OutputModalities);
}

[Fact]
public void Parse_FallsBackToTopProviderContextLength()
{
using var doc = JsonDocument.Parse(Ds4ModelsJson);
var probe = new BackendProbe("deepseek-v4-pro", doc.RootElement, PropsRoot: null);

var result = new Ds4BackendStrategy().Parse(probe);

Assert.NotNull(result);
Assert.Equal(196_608, result.ContextWindowTokens);
}

[Fact]
public void Parse_TreatsMissingContextAsUnknown()
{
const string json = """
{ "object": "list", "data": [ { "id": "deepseek-v4-flash", "owned_by": "ds4.c" } ] }
""";
using var doc = JsonDocument.Parse(json);
var probe = new BackendProbe("deepseek-v4-flash", doc.RootElement, PropsRoot: null);

var result = new Ds4BackendStrategy().Parse(probe);

Assert.NotNull(result);
Assert.Null(result.ContextWindowTokens);
}
}
Loading
Loading