Skip to content

Commit 8f18b96

Browse files
committed
feat: Add multi-provider instance support for LLM configurations
- Support multiple instances of same provider type (e.g., ollama_qwen3, ollama_deepseek) - Add provider type extraction from config or name prefix - Enhance provider validation with validate_provider_name() method - Add comprehensive multi-provider-guide.md documentation - Update configuration guide with multi-provider examples - Improve LLM provider manager initialization logic - Add quality score format option for automation - Add test sets and software lists for LLM comparison - Update schemas and CLI commands for better provider handling
1 parent 2c8352e commit 8f18b96

28 files changed

Lines changed: 1293 additions & 62 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,5 @@ logs/
198198
.coverage
199199
htmlcov/
200200
.pytest_cache/
201-
test-results/
201+
test-results/
202+
llm-comparison*

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Multi-Provider Instance Support**: Configure multiple instances of the same LLM provider type
12+
- Support for multiple Ollama models with unique names (e.g., `ollama_qwen3`, `ollama_deepseek`)
13+
- Support for multiple OpenAI endpoints (official, Azure, local)
14+
- New `provider` field in configuration to explicitly specify provider type
15+
- Provider type extraction from name prefix as fallback (e.g., `ollama_qwen3``ollama`)
16+
- Enhanced provider validation with `validate_provider_name()` method
17+
- New `extract_provider_type()` method for flexible provider type detection
18+
- Comprehensive multi-provider guide documentation
19+
- Support for model comparison workflows and A/B testing
20+
- **Quality Command Score Format**: New `--format score` option for `saigen quality` command
21+
- Returns just the numeric quality score (0.000-1.000) without additional text
22+
- Useful for automation, scripting, and CI/CD pipelines
23+
- Suppresses progress messages when using score format
24+
- Works with both overall score and specific metric scores
1125
- **API-Based Repository Support**: Complete implementation of API-based repository downloaders
1226
- New `ApiDownloader` class for fetching package data from REST APIs
1327
- Support for API-based repositories (Docker Hub, Hashicorp, etc.)
@@ -186,6 +200,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
186200
- **Security Enhancements**: File size limits for provider YAML files to prevent DoS attacks
187201

188202
### Changed
203+
- **LLM Provider Manager**: Enhanced to support multiple instances of the same provider type
204+
- Provider initialization now extracts base type from configuration or name
205+
- Improved error messages showing both provider name and type
206+
- Better handling of provider-specific configurations
207+
- Support for both dict and Pydantic model configs
208+
- **Configuration Documentation**: Enhanced configuration guide with multi-provider examples
209+
- Added comprehensive examples for multiple Ollama models
210+
- Added examples for multiple OpenAI endpoints
211+
- Documented naming conventions and best practices
212+
- Added troubleshooting section for common issues
189213
- **Repository Configuration Architecture**: Major restructuring of repository configuration system
190214
- Migrated from monolithic YAML files to individual provider configs
191215
- Enhanced schema with API endpoint and authentication support

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,17 @@ llm_providers:
481481
provider: anthropic
482482
model: claude-3-sonnet-20240229
483483
enabled: false
484+
# Multiple instances of the same provider type are supported
485+
ollama_qwen3:
486+
provider: ollama
487+
api_base: http://localhost:11434
488+
model: qwen3-coder:30b
489+
enabled: true
490+
ollama_deepseek:
491+
provider: ollama
492+
api_base: http://localhost:11434
493+
model: deepseek-r1:8b
494+
enabled: true
484495
485496
repositories:
486497
apt:
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Multi-Provider Instance Support Implementation
2+
3+
## Summary
4+
5+
Implemented support for multiple instances of the same LLM provider type (e.g., multiple Ollama models) with different configurations. Users can now configure multiple providers of the same type by using unique names in their configuration.
6+
7+
## Problem
8+
9+
Previously, the system only supported one instance per provider type (openai, anthropic, ollama, vllm). Users couldn't configure multiple Ollama models or multiple OpenAI endpoints with different settings. The code tried to convert provider names directly to the `LLMProvider` enum, which failed for names like `ollama_qwen3`.
10+
11+
## Solution
12+
13+
### 1. Provider Type Extraction
14+
15+
Added logic to extract the base provider type from provider names or configuration:
16+
- Provider names like `ollama_qwen3` are split to extract `ollama` as the type
17+
- The `provider` field in configuration explicitly specifies the type
18+
- New helper methods in `LLMProviderManager`:
19+
- `extract_provider_type()`: Extracts base provider type from name/config
20+
- `validate_provider_name()`: Validates provider names against configuration
21+
22+
### 2. Provider Name Handling
23+
24+
Updated all CLI commands to use provider names as strings instead of converting to enum:
25+
- `batch.py`: Validates provider names against config keys
26+
- `generate.py`: Uses provider names directly
27+
- `update.py`: Uses provider names directly
28+
- Better error messages showing available configured providers
29+
30+
### 3. Model Updates
31+
32+
Changed `GenerationRequest` and `BatchGenerationRequest` models:
33+
- `llm_provider` field changed from `LLMProvider` enum to `str`
34+
- Maintains backward compatibility with existing code
35+
- Generation engine already handled both string and enum values
36+
37+
### 4. Validation Improvements
38+
39+
Updated configuration validation:
40+
- Ollama and vLLM providers no longer require API keys
41+
- Validation checks provider existence in configuration
42+
- Clear error messages with list of available providers
43+
44+
### 5. Documentation Updates
45+
46+
Updated documentation to explain multi-provider support:
47+
- `README.md`: Added example with multiple Ollama instances
48+
- `saigen/docs/configuration-guide.md`: Added dedicated section on multi-provider instances
49+
- `saigen/docs/examples/saigen-config-sample.yaml`: Updated with multiple Ollama examples
50+
- CLI help text updated to reflect provider name usage
51+
52+
## Configuration Example
53+
54+
```yaml
55+
llm_providers:
56+
openai:
57+
provider: openai
58+
model: gpt-4o-mini
59+
enabled: true
60+
61+
# Multiple Ollama instances with different models
62+
ollama_qwen3:
63+
provider: ollama
64+
api_base: http://localhost:11434
65+
model: qwen3-coder:30b
66+
enabled: true
67+
68+
ollama_deepseek:
69+
provider: ollama
70+
api_base: http://localhost:11434
71+
model: deepseek-r1:8b
72+
enabled: true
73+
74+
ollama_phi3:
75+
provider: ollama
76+
api_base: http://localhost:11434
77+
model: phi3:latest
78+
enabled: true
79+
```
80+
81+
## Usage
82+
83+
```bash
84+
# Use specific provider
85+
saigen generate nginx --llm-provider ollama_qwen3
86+
87+
# Use different provider
88+
saigen batch software-list.txt --llm-provider ollama_deepseek
89+
90+
# Use default (first enabled provider)
91+
saigen generate nginx
92+
```
93+
94+
## Files Modified
95+
96+
### Core Implementation
97+
- `saigen/llm/provider_manager.py`: Added provider type extraction logic
98+
- `saigen/models/generation.py`: Changed llm_provider field to string
99+
- `saigen/core/update_engine.py`: Updated method signature
100+
101+
### CLI Commands
102+
- `saigen/cli/main.py`: Updated help text
103+
- `saigen/cli/commands/batch.py`: Provider name validation
104+
- `saigen/cli/commands/generate.py`: Provider name validation
105+
- `saigen/cli/commands/update.py`: Provider name validation
106+
107+
### Configuration & Validation
108+
- `saigen/utils/config.py`: Skip API key validation for ollama/vllm
109+
110+
### Documentation
111+
- `README.md`: Added multi-provider example
112+
- `saigen/docs/configuration-guide.md`: Added multi-provider section
113+
- `saigen/docs/examples/saigen-config-sample.yaml`: Updated examples
114+
115+
## Testing
116+
117+
All existing tests pass. The implementation:
118+
- Maintains backward compatibility with existing configurations
119+
- Handles both string and enum provider values in generation engine
120+
- Validates provider names against actual configuration
121+
- Provides clear error messages when invalid providers are specified
122+
123+
## Benefits
124+
125+
1. **Flexibility**: Users can configure multiple models of the same provider type
126+
2. **Model Comparison**: Easy to compare different models by switching providers
127+
3. **Resource Management**: Different models can use different endpoints/servers
128+
4. **Clear Naming**: Descriptive names like `ollama_qwen3` make it obvious which model is being used
129+
5. **Backward Compatible**: Existing single-provider configurations continue to work

sai/core/saidata_loader.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,7 @@ def _validate_services(
947947
"windows_service",
948948
"docker",
949949
"kubernetes",
950+
"none"
950951
]:
951952
errors.append(f"Service {i} has invalid type: {service_type}")
952953

saigen/cli/commands/batch.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,14 @@ def batch(
106106
# Use global LLM provider if specified, otherwise use config default
107107
llm_provider_name = ctx.obj["llm_provider"]
108108
if llm_provider_name:
109-
try:
110-
llm_provider = LLMProvider(llm_provider_name)
111-
except ValueError:
112-
raise click.BadParameter(f"Invalid LLM provider: {llm_provider_name}")
109+
# Validate that the provider exists in config
110+
if not config.llm_providers or llm_provider_name not in config.llm_providers:
111+
available = list(config.llm_providers.keys()) if config.llm_providers else []
112+
raise click.BadParameter(
113+
f"Invalid LLM provider: {llm_provider_name}. "
114+
f"Available providers: {', '.join(available) if available else 'none configured'}"
115+
)
116+
llm_provider = llm_provider_name
113117
else:
114118
# Use default from config or fallback
115119
if hasattr(config, "llm_providers") and config.llm_providers:
@@ -124,12 +128,9 @@ def batch(
124128
# No enabled providers, use first one anyway
125129
first_provider = next(iter(config.llm_providers.keys()), "openai")
126130

127-
try:
128-
llm_provider = LLMProvider(first_provider)
129-
except ValueError:
130-
llm_provider = LLMProvider.OPENAI # Fallback
131+
llm_provider = first_provider
131132
else:
132-
llm_provider = LLMProvider.OPENAI # Fallback
133+
llm_provider = "openai" # Fallback
133134

134135
# Validate concurrency
135136
if max_concurrent < 1 or max_concurrent > 20:

saigen/cli/commands/generate.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -200,43 +200,42 @@ def generate(
200200

201201
# Determine LLM provider
202202
if llm_provider:
203-
try:
204-
provider_enum = LLMProvider(llm_provider)
205-
except ValueError:
206-
error_msg = f"Invalid LLM provider '{llm_provider}'. Available: {
207-
[
208-
p.value for p in LLMProvider]}"
203+
# Validate that the provider exists in config
204+
if not config.llm_providers or llm_provider not in config.llm_providers:
205+
available = list(config.llm_providers.keys()) if config.llm_providers else []
206+
error_msg = (
207+
f"Invalid LLM provider '{llm_provider}'. "
208+
f"Available providers: {', '.join(available) if available else 'none configured'}"
209+
)
209210
if generation_logger:
210211
generation_logger.log_error(error_msg)
211212
click.echo(f"Error: {error_msg}", err=True)
212213
ctx.exit(1)
214+
provider_name = llm_provider
213215
else:
214216
# Use default from config or fallback
215217
if hasattr(config, "llm_providers") and config.llm_providers:
216218
# Get first enabled provider from config
217219
first_provider = None
218-
for provider_name, provider_config in config.llm_providers.items():
220+
for prov_name, provider_config in config.llm_providers.items():
219221
if provider_config.enabled:
220-
first_provider = provider_name
222+
first_provider = prov_name
221223
break
222224

223225
if not first_provider:
224226
# No enabled providers, use first one anyway
225227
first_provider = next(iter(config.llm_providers.keys()), "openai")
226228

227-
try:
228-
provider_enum = LLMProvider(first_provider)
229-
except ValueError:
230-
provider_enum = LLMProvider.OPENAI # Fallback
229+
provider_name = first_provider
231230
else:
232-
provider_enum = LLMProvider.OPENAI # Fallback
231+
provider_name = "openai" # Fallback
233232

234233
# Create generation request
235234
target_providers_list = list(providers) if providers else []
236235
request = GenerationRequest(
237236
software_name=software_name,
238237
target_providers=target_providers_list,
239-
llm_provider=provider_enum,
238+
llm_provider=provider_name,
240239
use_rag=not no_rag,
241240
user_hints=None,
242241
existing_saidata=None,
@@ -249,7 +248,7 @@ def generate(
249248
if verbose:
250249
click.echo(f"Generating saidata for: {software_name}")
251250
click.echo(f"Schema version: 0.3")
252-
click.echo(f"LLM Provider: {provider_enum.value}")
251+
click.echo(f"LLM Provider: {provider_name}")
253252
click.echo(f"Target providers: {request.target_providers}")
254253
click.echo(f"RAG enabled: {request.use_rag}")
255254
click.echo(f"Output file: {output}")

saigen/cli/commands/quality.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@
3131
@click.option(
3232
"--format",
3333
"output_format",
34-
type=click.Choice(["text", "json", "csv"]),
34+
type=click.Choice(["text", "json", "csv", "score"]),
3535
default="text",
36-
help="Output format",
36+
help="Output format (score: just the numeric value)",
3737
)
3838
@click.option("--export", type=click.Path(path_type=Path), help="Export detailed report to file")
3939
def quality(
@@ -121,17 +121,20 @@ async def _run_quality_assessment(
121121
repository_manager = RepositoryManager(cache_dir, config_dir)
122122
await repository_manager.initialize()
123123

124-
click.echo("🔍 Initializing repository data...", err=True)
124+
if output_format != "score":
125+
click.echo("🔍 Initializing repository data...", err=True)
125126
except Exception as e:
126-
click.echo(f"⚠️ Repository checking disabled: {e}", err=True)
127+
if output_format != "score":
128+
click.echo(f"⚠️ Repository checking disabled: {e}", err=True)
127129
check_repository_accuracy = False
128130

129131
# Create advanced validator
130132
base_validator = SaidataValidator()
131133
advanced_validator = AdvancedSaidataValidator(repository_manager, base_validator)
132134

133135
# Run quality assessment
134-
click.echo("📊 Assessing quality metrics...", err=True)
136+
if output_format != "score":
137+
click.echo("📊 Assessing quality metrics...", err=True)
135138
quality_report = await advanced_validator.validate_comprehensive(
136139
saidata, check_repository_accuracy
137140
)
@@ -152,6 +155,9 @@ async def _run_quality_assessment(
152155
elif output_format == "csv":
153156
output = _format_csv_output(quality_report, file_path)
154157
result_text = output
158+
elif output_format == "score":
159+
output = _format_score_output(quality_report, metric_filter)
160+
result_text = output
155161
else:
156162
output = _format_text_output(quality_report, file_path, threshold, metric_filter)
157163
result_text = output
@@ -168,7 +174,8 @@ async def _run_quality_assessment(
168174
json.dump(json.loads(result_text), f, indent=2)
169175
else:
170176
f.write(result_text)
171-
click.echo(f"📄 Report exported to {export_path}", err=True)
177+
if output_format != "score":
178+
click.echo(f"📄 Report exported to {export_path}", err=True)
172179

173180
# Clean up
174181
if repository_manager:
@@ -232,6 +239,18 @@ def _format_csv_output(quality_report, file_path: Path) -> str:
232239
return "\n".join(lines)
233240

234241

242+
def _format_score_output(quality_report, metric_filter: Optional[str]) -> str:
243+
"""Format quality report as just the score value."""
244+
if metric_filter:
245+
# If filtering by specific metric, show that metric's score
246+
metric_enum = QualityMetric(metric_filter)
247+
score = quality_report.metric_scores[metric_enum]
248+
return f"{score.score:.3f}"
249+
else:
250+
# Show overall score
251+
return f"{quality_report.overall_score:.3f}"
252+
253+
235254
def _format_text_output(
236255
quality_report, file_path: Path, threshold: float, metric_filter: Optional[str]
237256
) -> str:

0 commit comments

Comments
 (0)