Skip to content

Commit 8adfe73

Browse files
authored
feat: allow model registration without provider API keys (#5014)
# What does this PR do? Related to #5013. Add a `model_validation` configuration option for models that controls whether to validate model availability during registration. This enables multi-tenant deployments where llama-stack can start without API keys and accept per-request authentication via the `X-LlamaStack-Provider-Data` header. ## Key Changes 1. **Model-level `model_validation` field** (`ModelInput`, `RegisterModelRequest`): - `model_validation: false` (default) - Skip validation during registration, defer to runtime - `model_validation: true` - Validate model exists during registration (requires API key) - Models are preserved during provider refresh when validation is disabled 2. **Updated routing table logic**: - Models registered via API are preserved during refresh (not removed) - Debug logging added for skipped refreshes ## Test Plan ### Unit Tests Added 11 unit tests to `test_openai_mixin.py` covering: - Default behavior (no validation) - Model-level validation enabled/disabled - Interaction with `refresh_models` setting - Model registration success/failure scenarios ### Integration Test Started a llama-stack server with 4 providers to test different scenarios: <details> <summary>Test Configuration</summary> ```yaml providers: inference: # Scenario 1: No API key, no refresh (Gemini) - provider_id: gemini provider_type: remote::gemini config: {} # Scenario 2: No API key, no refresh (Anthropic) - provider_id: anthropic provider_type: remote::anthropic config: {} # Scenario 3: No API key, no refresh (OpenAI) - provider_id: openai-skip provider_type: remote::openai config: {} # Scenario 4: Real API key, refresh enabled (OpenAI) - provider_id: openai-real provider_type: remote::openai config: refresh_models: true api_key: sk-proj-... registered_resources: models: # Default behavior: no validation - provider_id: gemini model_id: models/gemini-2.5-flash-lite model_type: llm # Default behavior: no validation - provider_id: anthropic model_id: claude-haiku-4-5-20251001 model_type: llm # Default behavior: no validation - provider_id: openai-skip model_id: gpt-4o-mini model_type: llm # Explicit validation during registration - provider_id: openai-real model_id: gpt-4o-mini model_type: llm model_validation: true ``` </details> ### Results ✅ **All tests passed** 1. **Server startup**: All models registered successfully without requiring API keys 2. **Models API (`/v1/models`)**: All 4 test models appear in the response 3. **Inference without API key**: Providers 1-3 fail at runtime (expected), provider 4 succeeds with configured key 4. **Inference with per-request API key** (via `X-LlamaStack-Provider-Data` header): All providers succeed <details> <summary>Detailed Test Results</summary> ### 1. Server Startup ```bash $ llama stack run llama_stack/configs/test-skip-model-availability.yaml --port 8322 ``` ✅ Server started successfully. Models with no API keys registered without errors (validation deferred to runtime). ### 2. Verify Models Registered ```bash $ curl -s http://localhost:8322/v1/models | jq -r '.data[].id' | grep -E '(gemini/|anthropic/|openai-skip/|openai-real/)' | head -10 ``` ✅ Output: ``` gemini/models/gemini-2.5-flash-lite anthropic/claude-haiku-4-5-20251001 openai-skip/gpt-4o-mini openai-real/gpt-4o-mini ``` (Note: openai-real also auto-discovered 116+ additional models via `refresh_models: true`) ### 3. Inference Without Provider Data Header #### Gemini (no API key) ```bash $ curl -s http://localhost:8322/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "gemini/models/gemini-2.5-flash-lite", "messages": [{"role": "user", "content": "Say hello"}], "max_tokens": 50}' ``` ✅ Expected failure: ```json {"error": {"message": "API key not valid. Please pass a valid API key."}} ``` #### Anthropic (no API key) ```bash $ curl -s http://localhost:8322/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "anthropic/claude-haiku-4-5-20251001", "messages": [{"role": "user", "content": "Say hello"}], "max_tokens": 50}' ``` ✅ Expected failure: ```json {"error": {"message": "Invalid Anthropic API Key"}} ``` #### OpenAI-skip (no API key) ```bash $ curl -s http://localhost:8322/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "openai-skip/gpt-4o-mini", "messages": [{"role": "user", "content": "Say hello"}], "max_tokens": 50}' ``` ✅ Expected failure: ```json {"error": {"message": "API key not provided. Please provide a valid API key in the provider data header..."}} ``` #### OpenAI-real (configured API key) ```bash $ curl -s http://localhost:8322/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "openai-real/gpt-4o-mini", "messages": [{"role": "user", "content": "Say hello"}], "max_tokens": 50}' ``` ✅ Success: ```json { "choices": [{ "message": {"content": "Hello! How can I assist you today?", "role": "assistant"}, "finish_reason": "stop" }], "usage": {"completion_tokens": 9, "prompt_tokens": 9, "total_tokens": 18} } ``` ### 4. Inference With Per-Request API Keys #### Anthropic with provider data header ```bash $ curl -s http://localhost:8322/v1/chat/completions \ -H "Content-Type: application/json" \ -H 'X-LlamaStack-Provider-Data: {"anthropic_api_key": "sk-ant-api03-..."}' \ -d '{"model": "anthropic/claude-haiku-4-5-20251001", "messages": [{"role": "user", "content": "Say hello"}], "max_tokens": 50}' ``` ✅ Success: ```json { "choices": [{ "message": {"content": "Hello! 👋 How can I help you today?", "role": "assistant"}, "finish_reason": "stop" }], "usage": {"completion_tokens": 21, "prompt_tokens": 10, "total_tokens": 31} } ``` </details> ## Summary This PR enables multi-tenant deployments where: - **Models can be registered without API keys** (validation deferred to runtime) - **Per-request authentication works** via `X-LlamaStack-Provider-Data` header - **Explicit validation is opt-in** via `model_validation: true` on specific models - **Provider refresh is independent** and controlled by existing `refresh_models` setting
1 parent 6d99261 commit 8adfe73

9 files changed

Lines changed: 169 additions & 9 deletions

File tree

client-sdks/stainless/openapi.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7032,6 +7032,11 @@ components:
70327032
model_type:
70337033
$ref: '#/components/schemas/ModelType'
70347034
default: llm
7035+
model_validation:
7036+
anyOf:
7037+
- type: boolean
7038+
- type: 'null'
7039+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
70357040
type: object
70367041
required:
70377042
- identifier
@@ -12022,6 +12027,11 @@ components:
1202212027
- type: 'null'
1202312028
description: The type of model to register.
1202412029
title: ModelType
12030+
model_validation:
12031+
anyOf:
12032+
- type: boolean
12033+
- type: 'null'
12034+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
1202512035
type: object
1202612036
required:
1202712037
- model_id

docs/static/deprecated-llama-stack-spec.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3576,6 +3576,11 @@ components:
35763576
model_type:
35773577
$ref: '#/components/schemas/ModelType'
35783578
default: llm
3579+
model_validation:
3580+
anyOf:
3581+
- type: boolean
3582+
- type: 'null'
3583+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
35793584
type: object
35803585
required:
35813586
- identifier
@@ -8566,6 +8571,11 @@ components:
85668571
- type: 'null'
85678572
description: The type of model to register.
85688573
title: ModelType
8574+
model_validation:
8575+
anyOf:
8576+
- type: boolean
8577+
- type: 'null'
8578+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
85698579
type: object
85708580
required:
85718581
- model_id

docs/static/experimental-llama-stack-spec.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3685,6 +3685,11 @@ components:
36853685
model_type:
36863686
$ref: '#/components/schemas/ModelType'
36873687
default: llm
3688+
model_validation:
3689+
anyOf:
3690+
- type: boolean
3691+
- type: 'null'
3692+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
36883693
type: object
36893694
required:
36903695
- identifier
@@ -8646,6 +8651,11 @@ components:
86468651
- type: 'null'
86478652
description: The type of model to register.
86488653
title: ModelType
8654+
model_validation:
8655+
anyOf:
8656+
- type: boolean
8657+
- type: 'null'
8658+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
86498659
type: object
86508660
required:
86518661
- model_id

docs/static/llama-stack-spec.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5453,6 +5453,11 @@ components:
54535453
model_type:
54545454
$ref: '#/components/schemas/ModelType'
54555455
default: llm
5456+
model_validation:
5457+
anyOf:
5458+
- type: boolean
5459+
- type: 'null'
5460+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
54565461
type: object
54575462
required:
54585463
- identifier
@@ -10425,6 +10430,11 @@ components:
1042510430
- type: 'null'
1042610431
description: The type of model to register.
1042710432
title: ModelType
10433+
model_validation:
10434+
anyOf:
10435+
- type: boolean
10436+
- type: 'null'
10437+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
1042810438
type: object
1042910439
required:
1043010440
- model_id

docs/static/stainless-llama-stack-spec.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7032,6 +7032,11 @@ components:
70327032
model_type:
70337033
$ref: '#/components/schemas/ModelType'
70347034
default: llm
7035+
model_validation:
7036+
anyOf:
7037+
- type: boolean
7038+
- type: 'null'
7039+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
70357040
type: object
70367041
required:
70377042
- identifier
@@ -12022,6 +12027,11 @@ components:
1202212027
- type: 'null'
1202312028
description: The type of model to register.
1202412029
title: ModelType
12030+
model_validation:
12031+
anyOf:
12032+
- type: boolean
12033+
- type: 'null'
12034+
description: Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.
1202512035
type: object
1202612036
required:
1202712037
- model_id

src/llama_stack/core/routing_tables/models.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ async def refresh(self) -> None:
4646
try:
4747
models = await provider.list_models()
4848
except Exception as e:
49-
logger.warning(f"Model refresh failed for provider {provider_id}: {e}")
49+
if provider_id not in self.listed_providers:
50+
# Mark provider as listed to prevent repeated refresh attempts
51+
self.listed_providers.add(provider_id)
52+
logger.warning(f"Model refresh skipped for provider {provider_id}")
53+
else:
54+
logger.warning(f"Model refresh failed for provider {provider_id}: {e}")
5055
continue
5156

5257
self.listed_providers.add(provider_id)
@@ -210,14 +215,16 @@ async def register_model(
210215
provider_id: str | None = None,
211216
metadata: dict[str, Any] | None = None,
212217
model_type: ModelType | None = None,
218+
model_validation: bool | None = None,
213219
) -> Model:
214220
# Support both the public Models API (RegisterModelRequest) and legacy parameter-based interface
215221
if isinstance(request, RegisterModelRequest):
216222
model_id = request.model_id
217223
provider_model_id = request.provider_model_id
218224
provider_id = request.provider_id
219-
metadata = request.metadata
225+
metadata = request.metadata or {}
220226
model_type = request.model_type
227+
model_validation = request.model_validation
221228
elif isinstance(request, str):
222229
# Legacy positional argument: register_model("model-id", ...)
223230
model_id = request
@@ -248,6 +255,7 @@ async def register_model(
248255
provider_id=provider_id,
249256
metadata=metadata,
250257
model_type=model_type,
258+
model_validation=model_validation,
251259
source=RegistryEntrySource.via_register_api,
252260
)
253261
registered_model = await self.register_object(model)

src/llama_stack/providers/utils/inference/openai_mixin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,13 @@ async def openai_embeddings(
480480
##
481481

482482
async def register_model(self, model: Model) -> Model:
483+
# Check if we should validate model availability (defaults to False)
484+
should_validate = bool(model.model_validation)
485+
486+
if not should_validate:
487+
logger.debug(f"Skipping model availability check for {model.provider_model_id} (model_validation=false)")
488+
return model
489+
483490
if not await self.check_model_availability(model.provider_model_id):
484491
raise ValueError(f"Model {model.provider_model_id} is not available from provider {self.__provider_id__}") # type: ignore[attr-defined]
485492
return model

src/llama_stack_api/models/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ def provider_model_id(self) -> str:
6666
model_config = ConfigDict(protected_namespaces=())
6767

6868
model_type: ModelType = Field(default=ModelType.llm)
69+
model_validation: bool | None = Field(
70+
default=None,
71+
description="Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.",
72+
)
6973

7074
@field_validator("provider_resource_id")
7175
@classmethod
@@ -134,6 +138,10 @@ class RegisterModelRequest(BaseModel):
134138
provider_id: str | None = Field(default=None, description="The identifier of the provider.")
135139
metadata: dict[str, Any] | None = Field(default=None, description="Any additional metadata for this model.")
136140
model_type: ModelType | None = Field(default=None, description="The type of model to register.")
141+
model_validation: bool | None = Field(
142+
default=None,
143+
description="Enable model availability check during registration. When false (default), validation is deferred to runtime and model is preserved during provider refresh.",
144+
)
137145

138146

139147
@json_schema_type

tests/unit/providers/utils/inference/test_openai_mixin.py

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -515,13 +515,18 @@ async def test_check_model_availability_with_allowed_models(
515515
class TestOpenAIMixinModelRegistration:
516516
"""Test cases for model registration functionality"""
517517

518-
async def test_register_model_success(self, mixin, mock_client_with_models, mock_client_context):
518+
async def test_register_model_success(self, mock_client_with_models, mock_client_context):
519519
"""Test successful model registration when model is available"""
520+
config = RemoteInferenceProviderConfig()
521+
mixin = OpenAIMixinImpl(config=config)
522+
523+
# Enable validation for this model
520524
model = Model(
521525
provider_id="test-provider",
522526
provider_resource_id="some-mock-model-id",
523527
identifier="test-model",
524528
model_type=ModelType.llm,
529+
model_validation=True,
525530
)
526531

527532
with mock_client_context(mixin, mock_client_with_models):
@@ -534,13 +539,18 @@ async def test_register_model_success(self, mixin, mock_client_with_models, mock
534539
assert result.model_type == ModelType.llm
535540
mock_client_with_models.models.list.assert_called_once()
536541

537-
async def test_register_model_not_available(self, mixin, mock_client_with_models, mock_client_context):
542+
async def test_register_model_not_available(self, mock_client_with_models, mock_client_context):
538543
"""Test model registration failure when model is not available from provider"""
544+
config = RemoteInferenceProviderConfig()
545+
mixin = OpenAIMixinImpl(config=config)
546+
547+
# Enable validation for this model
539548
model = Model(
540549
provider_id="test-provider",
541550
provider_resource_id="non-existent-model",
542551
identifier="test-model",
543552
model_type=ModelType.llm,
553+
model_validation=True,
544554
)
545555

546556
with mock_client_context(mixin, mock_client_with_models):
@@ -550,24 +560,27 @@ async def test_register_model_not_available(self, mixin, mock_client_with_models
550560
await mixin.register_model(model)
551561
mock_client_with_models.models.list.assert_called_once()
552562

553-
async def test_register_model_with_allowed_models_filter(self, mixin, mock_client_with_models, mock_client_context):
563+
async def test_register_model_with_allowed_models_filter(self, mock_client_with_models, mock_client_context):
554564
"""Test model registration with allowed_models filtering"""
555-
mixin.config.allowed_models = ["some-mock-model-id"]
565+
config = RemoteInferenceProviderConfig(allowed_models=["some-mock-model-id"])
566+
mixin = OpenAIMixinImpl(config=config)
556567

557-
# Test with allowed model
568+
# Test with allowed model (with validation enabled)
558569
allowed_model = Model(
559570
provider_id="test-provider",
560571
provider_resource_id="some-mock-model-id",
561572
identifier="allowed-model",
562573
model_type=ModelType.llm,
574+
model_validation=True,
563575
)
564576

565-
# Test with disallowed model
577+
# Test with disallowed model (with validation enabled)
566578
disallowed_model = Model(
567579
provider_id="test-provider",
568580
provider_resource_id="final-mock-model-id",
569581
identifier="disallowed-model",
570582
model_type=ModelType.llm,
583+
model_validation=True,
571584
)
572585

573586
with mock_client_context(mixin, mock_client_with_models):
@@ -616,25 +629,99 @@ async def test_should_refresh_models(self, mixin):
616629
result = await mixin.should_refresh_models()
617630
assert result is False
618631

632+
# With refresh_models=True, should return True
619633
config_with_refresh = RemoteInferenceProviderConfig(refresh_models=True)
620634
mixin_with_refresh = OpenAIMixinImpl(config=config_with_refresh)
621635
result_with_refresh = await mixin_with_refresh.should_refresh_models()
622636
assert result_with_refresh is True
623637

624-
async def test_register_model_error_propagation(self, mixin, mock_client_with_exception, mock_client_context):
638+
async def test_register_model_error_propagation(self, mock_client_with_exception, mock_client_context):
625639
"""Test that errors from provider API are properly propagated during registration"""
640+
config = RemoteInferenceProviderConfig()
641+
mixin = OpenAIMixinImpl(config=config)
642+
643+
# Enable validation for this model
626644
model = Model(
627645
provider_id="test-provider",
628646
provider_resource_id="some-model",
629647
identifier="test-model",
630648
model_type=ModelType.llm,
649+
model_validation=True,
631650
)
632651

633652
with mock_client_context(mixin, mock_client_with_exception):
634653
# The exception from the API should be propagated
635654
with pytest.raises(Exception, match="API Error"):
636655
await mixin.register_model(model)
637656

657+
async def test_register_model_default_behavior_no_validation(self, mock_client_with_models, mock_client_context):
658+
"""Test model registration with default behavior (no validation)"""
659+
# Default behavior - no validation
660+
config = RemoteInferenceProviderConfig()
661+
mixin = OpenAIMixinImpl(config=config)
662+
663+
model = Model(
664+
provider_id="test-provider",
665+
provider_resource_id="non-existent-model",
666+
identifier="test-model",
667+
model_type=ModelType.llm,
668+
)
669+
670+
with mock_client_context(mixin, mock_client_with_models):
671+
# Should succeed without checking model availability (default behavior)
672+
result = await mixin.register_model(model)
673+
674+
assert result == model
675+
# Verify that models.list() was NOT called
676+
mock_client_with_models.models.list.assert_not_called()
677+
678+
async def test_register_model_with_validation_enabled(self, mock_client_with_models, mock_client_context):
679+
"""Test that model-level model_validation=True enables validation"""
680+
# Default config (no provider-level validation setting)
681+
config = RemoteInferenceProviderConfig()
682+
mixin = OpenAIMixinImpl(config=config)
683+
684+
# Model explicitly enables validation
685+
model = Model(
686+
provider_id="test-provider",
687+
provider_resource_id="non-existent-model",
688+
identifier="test-model",
689+
model_type=ModelType.llm,
690+
model_validation=True,
691+
)
692+
693+
with mock_client_context(mixin, mock_client_with_models):
694+
# Should fail because model-level validation is enabled
695+
with pytest.raises(ValueError, match="Model non-existent-model is not available"):
696+
await mixin.register_model(model)
697+
# Verify that models.list() WAS called (validation happened)
698+
mock_client_with_models.models.list.assert_called_once()
699+
700+
async def test_register_model_with_validation_explicitly_disabled(
701+
self, mock_client_with_models, mock_client_context
702+
):
703+
"""Test that model-level model_validation=False explicitly disables validation"""
704+
# Default config
705+
config = RemoteInferenceProviderConfig()
706+
mixin = OpenAIMixinImpl(config=config)
707+
708+
# Model explicitly disables validation (though this is the default anyway)
709+
model = Model(
710+
provider_id="test-provider",
711+
provider_resource_id="non-existent-model",
712+
identifier="test-model",
713+
model_type=ModelType.llm,
714+
model_validation=False,
715+
)
716+
717+
with mock_client_context(mixin, mock_client_with_models):
718+
# Should succeed because validation is disabled
719+
result = await mixin.register_model(model)
720+
721+
assert result == model
722+
# Verify that models.list() was NOT called
723+
mock_client_with_models.models.list.assert_not_called()
724+
638725

639726
class ProviderDataValidator(BaseModel):
640727
"""Validator for provider data in tests"""

0 commit comments

Comments
 (0)