Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ ENV=dev # options: dev|s

# Active LLM provider. Selects which provider answers credentials,
# metadata, and default-model lookups. Leave unset to default to nv_build.
# Options: openai | anthropic | anthropic_proxy | bedrock | nv_build |
# Options: openai | anthropic | anthropic_proxy | bedrock | minimax | nv_build |
# ollama | azure_openai | openai_compatible | gemini | claude_cli |
# gemini_cli | opencode_cli
# codex_cli is registered but disabled: its read-only sandbox permits host-file reads.
Expand Down Expand Up @@ -50,6 +50,13 @@ SKILLSPECTOR_SEED= # integer
# For SKILLSPECTOR_PROVIDER=anthropic.
ANTHROPIC_API_KEY=

# For SKILLSPECTOR_PROVIDER=minimax. MINIMAX_REGION accepts global_en or cn_zh.
# Defaults to MiniMax-M3.1-Flash; SKILLSPECTOR_MODEL can select MiniMax-M3 or an explicit model.
# Flash accepts reasoning effort low, medium, high, xhigh or max (endpoint default).
MINIMAX_API_KEY=
MINIMAX_REGION=global_en
# MINIMAX_BASE_URL= # optional override

# For SKILLSPECTOR_PROVIDER=anthropic_proxy (Vertex-style raw-predict proxy).
# Supports corporate API gateways, GCP Vertex AI, and self-hosted proxies.
ANTHROPIC_PROXY_ENDPOINT_URL=
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ inference gateways.
| `openai` | `OPENAI_API_KEY` (+ optional `OPENAI_BASE_URL`) | api.openai.com (or any OpenAI-compatible URL) | `gpt-5.4` |
| `anthropic` | `ANTHROPIC_API_KEY` | api.anthropic.com | `claude-opus-4-6` (`meta_analyzer`: `claude-sonnet-4-6`) |
| `anthropic_proxy` | `ANTHROPIC_PROXY_API_KEY` + `ANTHROPIC_PROXY_ENDPOINT_URL` | Any Vertex-style raw-predict proxy | `claude-sonnet-4-6` |
| `minimax` | `MINIMAX_API_KEY` (+ optional `MINIMAX_REGION`) | Global or China regional endpoint | `MiniMax-M3.1-Flash` |
| `bedrock` | `AWS_PROFILE` (optional) + `AWS_REGION` — SigV4 via boto3 | AWS Bedrock Runtime | `us.anthropic.claude-sonnet-4-6` |
| `nv_build` | `NVIDIA_INFERENCE_KEY` | build.nvidia.com | `z-ai/glm-5.3` |
| `gemini` | `GOOGLE_CLOUD_PROJECT` (+ optional `GOOGLE_CLOUD_LOCATION`) via ADC | Google Cloud OpenAI-compatible Gemini endpoint | `gemini-3.8-flash` |
Expand Down Expand Up @@ -380,6 +381,15 @@ export SKILLSPECTOR_PROVIDER=nv_build
export NVIDIA_INFERENCE_KEY=nvapi-...
skillspector scan ./my-skill/

# MiniMax global endpoint; use cn_zh for the China endpoint
export SKILLSPECTOR_PROVIDER=minimax
export MINIMAX_API_KEY=your-key
export MINIMAX_REGION=global_en
# Default: MiniMax-M3.1-Flash; set SKILLSPECTOR_MODEL=MiniMax-M3 to select M3.
# Flash reasoning effort: low, medium, high, xhigh, max (endpoint default).
# Explicit legacy/custom model IDs remain supported through SKILLSPECTOR_MODEL.
skillspector scan ./my-skill/

# Gemini on Google Cloud (Application Default Credentials / Workload Identity)
# Prerequisites:
# 1. Google Cloud project with billing enabled.
Expand Down Expand Up @@ -734,7 +744,7 @@ Issues (2)

| Variable | Description | Required |
|----------|-------------|----------|
| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `gemini`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `gemini_cli`, or `opencode_cli`. Hosted providers use bundled `model_registry.yaml` defaults; CLI providers fall back to the local runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional |
| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `minimax`, `nv_build`, `gemini`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `gemini_cli`, or `opencode_cli`. Hosted providers use bundled `model_registry.yaml` defaults; CLI providers fall back to the local runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional |
| `GOOGLE_CLOUD_PROJECT` | Google Cloud project ID for the `gemini` provider. Authenticates via Google Cloud Application Default Credentials (ADC) or GKE Workload Identity. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=gemini` |
| `GOOGLE_CLOUD_LOCATION` | Google Cloud location for the `gemini` provider endpoint (e.g. `global`, `us`, `eu`, `us-central1`). Defaults to `global`. | Optional (used when `SKILLSPECTOR_PROVIDER=gemini`) |
| `GOOGLE_APPLICATION_CREDENTIALS` | Optional path to ADC credential/config file (e.g. Workload or Workforce Identity Federation config; exported service account keys are discouraged). For local development, use `gcloud auth application-default login`; for GKE, use Workload Identity. | Optional (used when `SKILLSPECTOR_PROVIDER=gemini`) |
Expand All @@ -748,6 +758,9 @@ Issues (2)
| `SKILLSPECTOR_COMPACT_PROMPTS` | Opt-in compact line numbering in LLM prompts: numbered lines render as `L1:`, `L2:` instead of zero-padded `L01:`, `L02:`. Accepted truthy values are `1`, `true`, and `yes` (case-insensitive; surrounding whitespace is trimmed). Unset or any other value keeps the default zero-padded format. | Optional |
| `ANTHROPIC_API_KEY` | Credential for the Anthropic provider (`SKILLSPECTOR_PROVIDER=anthropic`). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=anthropic` |
| `ANTHROPIC_BASE_URL` | Override the native Anthropic endpoint (default: `https://api.anthropic.com`). | Optional |
| `MINIMAX_API_KEY` | Credential for the MiniMax provider (`SKILLSPECTOR_PROVIDER=minimax`). | Required when `SKILLSPECTOR_PROVIDER=minimax` |
| `MINIMAX_REGION` | MiniMax regional endpoint: `global_en` (default) or `cn_zh`. | Optional |
| `MINIMAX_BASE_URL` | Override the selected MiniMax regional endpoint. | Optional |
| `ANTHROPIC_AUTH_SCHEME` | Set to `bearer` to send `ANTHROPIC_API_KEY` as `Authorization: Bearer` instead of `x-api-key`, for gateways exposing the Messages API. | Optional |
| `ANTHROPIC_PROXY_ENDPOINT_URL` | Full endpoint URL for the Anthropic proxy provider (Vertex-style raw-predict). | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` |
| `ANTHROPIC_PROXY_API_KEY` | Bearer token for the Anthropic proxy provider. | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` |
Expand Down
3 changes: 2 additions & 1 deletion src/skillspector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ def scan(
Environment variables:

SKILLSPECTOR_PROVIDER Active LLM provider: openai | anthropic |
anthropic_proxy | bedrock | nv_build |
anthropic_proxy | bedrock | minimax | nv_build |
nv_inference | ollama | azure_openai |
openai_compatible | gemini | claude_cli |
gemini_cli | opencode_cli. Defaults to the NVIDIA path
Expand All @@ -657,6 +657,7 @@ def scan(

OPENAI_API_KEY [+ OPENAI_BASE_URL] for SKILLSPECTOR_PROVIDER=openai
ANTHROPIC_API_KEY for SKILLSPECTOR_PROVIDER=anthropic
MINIMAX_API_KEY for SKILLSPECTOR_PROVIDER=minimax
ANTHROPIC_PROXY_API_KEY +
ANTHROPIC_PROXY_ENDPOINT_URL for anthropic_proxy
AWS_PROFILE (optional) + AWS_REGION for SKILLSPECTOR_PROVIDER=bedrock
Expand Down
1 change: 1 addition & 0 deletions src/skillspector/inference_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ def provider_name(provider: object) -> str:
"CodexCLIProvider": "codex_cli",
"GeminiCLIProvider": "gemini_cli",
"GeminiProvider": "gemini",
"MiniMaxProvider": "minimax",
"NvBuildProvider": "nv_build",
"NvInferenceProvider": "nv_inference",
"OllamaProvider": "ollama",
Expand Down
4 changes: 3 additions & 1 deletion src/skillspector/llm_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"anthropic_proxy",
"azure_openai",
"bedrock",
"minimax",
"nv_build",
"nv_inference",
"ollama",
Expand All @@ -45,12 +46,13 @@
}
)
_SEED_ADAPTERS = frozenset(
{"azure_openai", "nv_build", "nv_inference", "ollama", "openai", "openai_compatible"}
{"azure_openai", "minimax", "nv_build", "nv_inference", "ollama", "openai", "openai_compatible"}
)
_REASONING_EFFORT_ADAPTERS = frozenset(
{
"anthropic",
"anthropic_proxy",
"minimax",
"nv_build",
"nv_inference",
"ollama",
Expand Down
7 changes: 6 additions & 1 deletion src/skillspector/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
anthropic → AnthropicProvider (api.anthropic.com)
anthropic_proxy → AnthropicProxyProvider (Vertex-style raw-predict proxy)
bedrock → BedrockProvider (AWS Bedrock Runtime, SigV4)
minimax → MiniMaxProvider (global or China regional endpoint)
nv_build → NvBuildProvider (build.nvidia.com)
ollama → OllamaProvider (local Ollama instance)
azure_openai → AzureOpenAIProvider (Azure OpenAI Service)
Expand Down Expand Up @@ -127,6 +128,10 @@ def _select_active_provider() -> LLMProvider:
from .bedrock import BedrockProvider

return BedrockProvider()
if name == "minimax":
from .minimax import MiniMaxProvider

return MiniMaxProvider()
if name == "ollama":
from .ollama import OllamaProvider

Expand Down Expand Up @@ -177,7 +182,7 @@ def _select_active_provider() -> LLMProvider:

raise UnknownProviderError(
f"Unknown SKILLSPECTOR_PROVIDER: {name!r}. "
"Expected one of: openai, anthropic, anthropic_proxy, bedrock, nv_build, "
"Expected one of: openai, anthropic, anthropic_proxy, bedrock, minimax, nv_build, "
"ollama, azure_openai, openai_compatible, gemini, "
"claude_cli, codex_cli, gemini_cli, opencode_cli, antigravity_cli (or unset)."
)
Expand Down
3 changes: 2 additions & 1 deletion src/skillspector/providers/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ def create_openai_compatible_chat_model(
default_headers: dict[str, str] | None = None,
forced_tool_choice: bool = True,
default_reasoning_effort: str | None = None,
chat_model_class: type[ChatOpenAI] | None = None,
) -> BaseChatModel | None:
"""Create ``ChatOpenAI`` for providers serving OpenAI-compatible endpoints.

Expand Down Expand Up @@ -212,7 +213,7 @@ def create_openai_compatible_chat_model(
model, include_seed=True, reasoning_effort=reasoning_effort
)
kwargs.update(sampling_parameters)
chat_model = ChatOpenAI(**kwargs)
chat_model = (chat_model_class or ChatOpenAI)(**kwargs)
register_chat_model_controls(
chat_model,
retained_chat_model_controls(
Expand Down
30 changes: 30 additions & 0 deletions src/skillspector/providers/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""MiniMax provider package."""

from .provider import (
MINIMAX_CN_BASE_URL,
MINIMAX_GLOBAL_BASE_URL,
REGISTRY_PATH,
MiniMaxProvider,
)

__all__ = [
"MINIMAX_CN_BASE_URL",
"MINIMAX_GLOBAL_BASE_URL",
"REGISTRY_PATH",
"MiniMaxProvider",
]
73 changes: 73 additions & 0 deletions src/skillspector/providers/minimax/chat_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Preserve MiniMax reasoning across OpenAI-compatible tool continuations."""

from typing import Any

import openai
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI


class MiniMaxChatOpenAI(ChatOpenAI):
"""Keep the reasoning field that generic ChatOpenAI intentionally discards."""

def _get_request_payload(
self,
input_: LanguageModelInput,
*,
stop: list[str] | None = None,
**kwargs: Any,
) -> dict:
messages = self._convert_input(input_).to_messages()
payload = super()._get_request_payload(messages, stop=stop, **kwargs)
for message, serialized in zip(messages, payload.get("messages", []), strict=False):
if isinstance(message, AIMessage):
reasoning = message.additional_kwargs.get("reasoning_content")
if isinstance(reasoning, str):
serialized["reasoning_content"] = reasoning
return payload

def _create_chat_result(
self,
response: dict | openai.BaseModel,
generation_info: dict | None = None,
) -> ChatResult:
result = super()._create_chat_result(response, generation_info)
data = response if isinstance(response, dict) else response.model_dump()
for generation, choice in zip(result.generations, data.get("choices", []), strict=False):
reasoning = choice.get("message", {}).get("reasoning_content")
if isinstance(reasoning, str):
generation.message.additional_kwargs["reasoning_content"] = reasoning
return result

def _convert_chunk_to_generation_chunk(
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
result = super()._convert_chunk_to_generation_chunk(
chunk, default_chunk_class, base_generation_info
)
choices = chunk.get("choices", [])
if result is not None and choices:
reasoning = choices[0].get("delta", {}).get("reasoning_content")
if isinstance(reasoning, str):
result.message.additional_kwargs["reasoning_content"] = reasoning
return result
14 changes: 14 additions & 0 deletions src/skillspector/providers/minimax/model_registry.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Token-budget metadata for the MiniMax provider.
#
# Format:
# models:
# "<model-label>":
# context_length: <int>
# max_output_tokens: <int> # optional when no output cap is published

models:
"MiniMax-M3.1-Flash":
context_length: 1000000
max_output_tokens: 524288
"MiniMax-M3":
context_length: 1000000
112 changes: 112 additions & 0 deletions src/skillspector/providers/minimax/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""MiniMax provider with global and China regional endpoint selection.

``MINIMAX_REGION`` accepts ``global_en`` (the default) or ``cn_zh``.
``MINIMAX_BASE_URL`` can override the selected regional endpoint.
"""

from __future__ import annotations

import os
from pathlib import Path

from langchain_core.language_models.chat_models import BaseChatModel

from skillspector.providers import registry
from skillspector.providers.chat_models import (
UnsupportedControlError,
create_openai_compatible_chat_model,
resolve_reasoning_effort,
)
from skillspector.providers.minimax.chat_model import MiniMaxChatOpenAI

MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io/v1"
MINIMAX_CN_BASE_URL = "https://api.minimaxi.com/v1"
MINIMAX_BASE_URLS = {
"global_en": MINIMAX_GLOBAL_BASE_URL,
"cn_zh": MINIMAX_CN_BASE_URL,
}

REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml"))


def _resolve_base_url() -> str:
override = os.environ.get("MINIMAX_BASE_URL", "").strip()
if override:
return override

region = os.environ.get("MINIMAX_REGION", "").strip().lower() or "global_en"
try:
return MINIMAX_BASE_URLS[region]
except KeyError as exc:
raise ValueError("MINIMAX_REGION must be 'global_en' or 'cn_zh'") from exc


class MiniMaxProvider:
"""MiniMax credentials, regional routing, and bundled model metadata."""

DEFAULT_MODEL = "MiniMax-M3.1-Flash"
SLOT_DEFAULTS: dict[str, str] = {}

def resolve_credentials(self) -> tuple[str, str | None] | None:
"""Return the MiniMax API key and selected regional base URL."""
api_key = os.environ.get("MINIMAX_API_KEY", "").strip()
if not api_key:
return None
return api_key, _resolve_base_url()

def create_chat_model(
self,
model: str,
*,
max_tokens: int,
timeout: float | None = 120,
) -> BaseChatModel | None:
"""Create a chat model for the selected MiniMax endpoint."""
effort = resolve_reasoning_effort()
allowed_efforts = ("low", "medium", "high", "xhigh", "max")
if model == "MiniMax-M3.1-Flash" and effort and effort not in allowed_efforts:
raise UnsupportedControlError(
f"SKILLSPECTOR_REASONING_EFFORT={effort!r} is not supported by {model}; "
f"use one of {', '.join(allowed_efforts)} or unset it"
)
return create_openai_compatible_chat_model(
model=model,
credentials=self.resolve_credentials(),
max_tokens=max_tokens,
timeout=timeout,
chat_model_class=MiniMaxChatOpenAI,
)

def structured_output_method(self, model: str) -> str:
"""Use the documented tool-calling API for scanner schemas."""
return registry.lookup_structured_output_method(REGISTRY_PATH, model) or "function_calling"

def get_context_length(self, model: str) -> int | None:
context_length = registry.lookup_context_length(REGISTRY_PATH, model)
if context_length is None and not os.environ.get("SKILLSPECTOR_MODEL_REGISTRY", "").strip():
# Keep explicit legacy requests' budget without advertising them in the catalog.
return {"MiniMax-M2.7": 204_800}.get(model)
return context_length

def get_max_output_tokens(self, model: str) -> int | None:
return registry.lookup_max_output_tokens(REGISTRY_PATH, model)

def resolve_model(self, slot: str = "default") -> str:
"""Resolve model from an environment override or the bundled default."""
user_input = os.environ.get("SKILLSPECTOR_MODEL", "").strip()
return user_input or self.SLOT_DEFAULTS.get(slot, "") or self.DEFAULT_MODEL
Loading
Loading