Skip to content

Commit 974884c

Browse files
authored
Unified API: Add support for Kaapi Abstracted LLM Call (#498)
* Add Kaapi LLM parameters and completion config; implement transformation to native provider format * Refine LLM API documentation and improve code formatting for clarity; enhance configuration handling for OpenAI provider * add/fix tests * Fix validation logic in map_kaapi_to_openai_params to prevent simultaneous setting of 'temperature' and 'reasoning' parameters * Remove default value for 'model' in KaapiLLMParams to enforce explicit assignment * Refactor KaapiLLMParams to enforce explicit reasoning levels; update mapping logic to handle reasoning and temperature conflicts with warnings * Enhance LLM API documentation to clarify ad-hoc configuration parameters and warning handling for unsupported settings * Refactor execute_job to use completion_config directly instead of config_blob.completion * Refactor LLM provider interfaces to use NativeCompletionConfig instead of CompletionConfig * precommit
1 parent 46f0c19 commit 974884c

File tree

19 files changed

+1024
-88
lines changed

19 files changed

+1024
-88
lines changed

backend/app/api/docs/llm/llm_call.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ for processing, and results are delivered via the callback URL when complete.
2121
- **Note**: When using stored configuration, do not include the `blob` field in the request body
2222

2323
- **Mode 2: Ad-hoc Configuration**
24-
- `blob` (object): Complete configuration object (see Create Config endpoint documentation for examples)
25-
- `completion` (required):
26-
- `provider` (required, string): Currently only "openai"
27-
- `params` (required, object): Provider-specific parameters (flexible JSON)
28-
- **Note**: When using ad-hoc configuration, do not include `id` and `version` fields
24+
- `blob` (object): Complete configuration object
25+
- `completion` (required, object): Completion configuration
26+
- `provider` (required, string): Provider type - either `"openai"` (Kaapi abstraction) or `"openai-native"` (pass-through)
27+
- `params` (required, object): Parameters structure depends on provider type (see schema for detailed structure)
28+
- **Note**
29+
- When using ad-hoc configuration, do not include `id` and `version` fields
30+
- When using the Kaapi abstraction, parameters that are not supported by the selected provider or model are automatically suppressed. If any parameters are ignored, a list of warnings is included in the metadata.warnings. For example, the GPT-5 model does not support the temperature parameter, so Kaapi will neither throw an error nor pass this parameter to the model; instead, it will return a warning in the metadata.warnings response.
31+
- **Recommendation**: Use stored configs (Mode 1) for production; use ad-hoc configs only for testing/validation
32+
- **Schema**: Check the API schema or examples below for the complete parameter structure for each provider type
2933

3034
**`callback_url`** (optional, HTTPS URL):
3135
- Webhook endpoint to receive the response
@@ -39,4 +43,7 @@ for processing, and results are delivered via the callback URL when complete.
3943
- Custom JSON metadata
4044
- Passed through unchanged in the response
4145

46+
### Note
47+
- `warnings` list is automatically added in response metadata when using Kaapi configs if any parameters are suppressed or adjusted (e.g., temperature on reasoning models)
48+
4249
---

backend/app/core/langfuse/langfuse.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from asgi_correlation_id import correlation_id
77
from langfuse import Langfuse
88
from langfuse.client import StatefulGenerationClient, StatefulTraceClient
9-
from app.models.llm import CompletionConfig, QueryParams, LLMCallResponse
9+
from app.models.llm import NativeCompletionConfig, QueryParams, LLMCallResponse
1010

1111
logger = logging.getLogger(__name__)
1212

@@ -130,7 +130,9 @@ def observe_llm_execution(
130130

131131
def decorator(func: Callable) -> Callable:
132132
@wraps(func)
133-
def wrapper(completion_config: CompletionConfig, query: QueryParams, **kwargs):
133+
def wrapper(
134+
completion_config: NativeCompletionConfig, query: QueryParams, **kwargs
135+
):
134136
# Skip observability if no credentials provided
135137
if not credentials:
136138
logger.info("[Langfuse] No credentials - skipping observability")

backend/app/models/llm/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@
33
CompletionConfig,
44
QueryParams,
55
ConfigBlob,
6+
KaapiLLMParams,
7+
KaapiCompletionConfig,
8+
NativeCompletionConfig,
69
)
710
from app.models.llm.response import LLMCallResponse, LLMResponse, LLMOutput, Usage

backend/app/models/llm/request.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,44 @@
1-
from typing import Any, Literal
1+
from typing import Annotated, Any, Literal, Union
22

33
from uuid import UUID
44
from sqlmodel import Field, SQLModel
5-
from pydantic import model_validator, HttpUrl
5+
from pydantic import Discriminator, model_validator, HttpUrl
6+
7+
8+
class KaapiLLMParams(SQLModel):
9+
"""
10+
Kaapi-abstracted parameters for LLM providers.
11+
These parameters are mapped internally to provider-specific API parameters.
12+
Provides a unified contract across all LLM providers (OpenAI, Claude, Gemini, etc.).
13+
Provider-specific mappings are handled at the mapper level.
14+
"""
15+
16+
model: str = Field(
17+
description="Model identifier to use for completion (e.g., 'gpt-4o', 'gpt-5')",
18+
)
19+
instructions: str | None = Field(
20+
default=None,
21+
description="System instructions to guide the model's behavior",
22+
)
23+
knowledge_base_ids: list[str] | None = Field(
24+
default=None,
25+
description="List of vector store IDs to use for knowledge retrieval",
26+
)
27+
reasoning: Literal["low", "medium", "high"] | None = Field(
28+
default=None,
29+
description="Reasoning configuration or instructions",
30+
)
31+
temperature: float | None = Field(
32+
default=None,
33+
ge=0.0,
34+
le=2.0,
35+
description="Sampling temperature between 0 and 2",
36+
)
37+
max_num_results: int | None = Field(
38+
default=None,
39+
ge=1,
40+
description="Maximum number of results to return",
41+
)
642

743

844
class ConversationConfig(SQLModel):
@@ -46,18 +82,44 @@ class QueryParams(SQLModel):
4682
)
4783

4884

49-
class CompletionConfig(SQLModel):
50-
"""Completion configuration with provider and parameters."""
85+
class NativeCompletionConfig(SQLModel):
86+
"""
87+
Native provider configuration (pass-through).
88+
All parameters are forwarded as-is to the provider's API without transformation.
89+
Supports any LLM provider's native API format.
90+
"""
5191

52-
provider: Literal["openai"] = Field(
53-
default="openai", description="LLM provider to use"
92+
provider: Literal["openai-native"] = Field(
93+
default="openai-native",
94+
description="Native provider type (e.g., openai-native)",
5495
)
5596
params: dict[str, Any] = Field(
5697
...,
5798
description="Provider-specific parameters (schema varies by provider), should exactly match the provider's endpoint params structure",
5899
)
59100

60101

102+
class KaapiCompletionConfig(SQLModel):
103+
"""
104+
Kaapi abstraction for LLM completion providers.
105+
Uses standardized Kaapi parameters that are mapped to provider-specific APIs internally.
106+
Supports multiple providers: OpenAI, Claude, Gemini, etc.
107+
"""
108+
109+
provider: Literal["openai"] = Field(..., description="LLM provider (openai)")
110+
params: KaapiLLMParams = Field(
111+
...,
112+
description="Kaapi-standardized parameters mapped to provider-specific API",
113+
)
114+
115+
116+
# Discriminated union for completion configs based on provider field
117+
CompletionConfig = Annotated[
118+
Union[NativeCompletionConfig, KaapiCompletionConfig],
119+
Field(discriminator="provider"),
120+
]
121+
122+
61123
class ConfigBlob(SQLModel):
62124
"""Raw JSON blob of config."""
63125

backend/app/services/llm/jobs.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
from app.crud.credentials import get_provider_credential
1111
from app.crud.jobs import JobCrud
1212
from app.models import JobStatus, JobType, JobUpdate, LLMCallRequest
13-
from app.models.llm.request import ConfigBlob, LLMCallConfig
13+
from app.models.llm.request import ConfigBlob, LLMCallConfig, KaapiCompletionConfig
1414
from app.utils import APIResponse, send_callback
1515
from app.celery.utils import start_high_priority_job
1616
from app.core.langfuse.langfuse import observe_llm_execution
1717
from app.services.llm.providers.registry import get_llm_provider
18+
from app.services.llm.mappers import transform_kaapi_config_to_native
1819

1920

2021
logger = logging.getLogger(__name__)
@@ -170,10 +171,27 @@ def execute_job(
170171
else:
171172
config_blob = config.blob
172173

174+
try:
175+
# Transform Kaapi config to native config if needed (before getting provider)
176+
completion_config = config_blob.completion
177+
if isinstance(completion_config, KaapiCompletionConfig):
178+
completion_config, warnings = transform_kaapi_config_to_native(
179+
completion_config
180+
)
181+
if request.request_metadata is None:
182+
request.request_metadata = {}
183+
request.request_metadata.setdefault("warnings", []).extend(warnings)
184+
except Exception as e:
185+
callback_response = APIResponse.failure_response(
186+
error=f"Error processing configuration: {str(e)}",
187+
metadata=request.request_metadata,
188+
)
189+
return handle_job_error(job_id, request.callback_url, callback_response)
190+
173191
try:
174192
provider_instance = get_llm_provider(
175193
session=session,
176-
provider_type=config_blob.completion.provider,
194+
provider_type=completion_config.provider, # Now always native provider type
177195
project_id=project_id,
178196
organization_id=organization_id,
179197
)
@@ -203,7 +221,7 @@ def execute_job(
203221
)(provider_instance.execute)
204222

205223
response, error = decorated_execute(
206-
completion_config=config_blob.completion,
224+
completion_config=completion_config,
207225
query=request.query,
208226
include_provider_raw_response=request.include_provider_raw_response,
209227
)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Parameter mappers for converting Kaapi-abstracted parameters to provider-specific formats."""
2+
3+
import litellm
4+
from app.models.llm import KaapiLLMParams, KaapiCompletionConfig, NativeCompletionConfig
5+
6+
7+
def map_kaapi_to_openai_params(kaapi_params: KaapiLLMParams) -> tuple[dict, list[str]]:
8+
"""Map Kaapi-abstracted parameters to OpenAI API parameters.
9+
10+
This mapper transforms standardized Kaapi parameters into OpenAI-specific
11+
parameter format, enabling provider-agnostic interface design.
12+
13+
Args:
14+
kaapi_params: KaapiLLMParams instance with standardized parameters
15+
16+
Supported Mapping:
17+
- model → model
18+
- instructions → instructions
19+
- knowledge_base_ids → tools[file_search].vector_store_ids
20+
- max_num_results → tools[file_search].max_num_results (fallback default)
21+
- reasoning → reasoning.effort (if reasoning supported by model else suppressed)
22+
- temperature → temperature (if reasoning not supported by model else suppressed)
23+
24+
Returns:
25+
Tuple of:
26+
- Dictionary of OpenAI API parameters ready to be passed to the API
27+
- List of warnings describing suppressed or ignored parameters
28+
"""
29+
openai_params = {}
30+
warnings = []
31+
32+
support_reasoning = litellm.supports_reasoning(
33+
model="openai/" + f"{kaapi_params.model}"
34+
)
35+
36+
# Handle reasoning vs temperature mutual exclusivity
37+
if support_reasoning:
38+
if kaapi_params.reasoning is not None:
39+
openai_params["reasoning"] = {"effort": kaapi_params.reasoning}
40+
41+
if kaapi_params.temperature is not None:
42+
warnings.append(
43+
"Parameter 'temperature' was suppressed because the selected model "
44+
"supports reasoning, and temperature is ignored when reasoning is enabled."
45+
)
46+
else:
47+
if kaapi_params.reasoning is not None:
48+
warnings.append(
49+
"Parameter 'reasoning' was suppressed because the selected model "
50+
"does not support reasoning."
51+
)
52+
53+
if kaapi_params.temperature is not None:
54+
openai_params["temperature"] = kaapi_params.temperature
55+
56+
if kaapi_params.model:
57+
openai_params["model"] = kaapi_params.model
58+
59+
if kaapi_params.instructions:
60+
openai_params["instructions"] = kaapi_params.instructions
61+
62+
if kaapi_params.knowledge_base_ids:
63+
openai_params["tools"] = [
64+
{
65+
"type": "file_search",
66+
"vector_store_ids": kaapi_params.knowledge_base_ids,
67+
"max_num_results": kaapi_params.max_num_results or 20,
68+
}
69+
]
70+
71+
return openai_params, warnings
72+
73+
74+
def transform_kaapi_config_to_native(
75+
kaapi_config: KaapiCompletionConfig,
76+
) -> tuple[NativeCompletionConfig, list[str]]:
77+
"""Transform Kaapi completion config to native provider config with mapped parameters.
78+
79+
Currently supports OpenAI. Future: Claude, Gemini mappers.
80+
81+
Args:
82+
kaapi_config: KaapiCompletionConfig with abstracted parameters
83+
84+
Returns:
85+
NativeCompletionConfig with provider-native parameters ready for API
86+
"""
87+
if kaapi_config.provider == "openai":
88+
mapped_params, warnings = map_kaapi_to_openai_params(kaapi_config.params)
89+
return (
90+
NativeCompletionConfig(provider="openai-native", params=mapped_params),
91+
warnings,
92+
)
93+
94+
raise ValueError(f"Unsupported provider: {kaapi_config.provider}")

backend/app/services/llm/providers/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from abc import ABC, abstractmethod
88
from typing import Any
99

10-
from app.models.llm import CompletionConfig, LLMCallResponse, QueryParams
10+
from app.models.llm import NativeCompletionConfig, LLMCallResponse, QueryParams
1111

1212

1313
class BaseProvider(ABC):
@@ -34,7 +34,7 @@ def __init__(self, client: Any):
3434
@abstractmethod
3535
def execute(
3636
self,
37-
completion_config: CompletionConfig,
37+
completion_config: NativeCompletionConfig,
3838
query: QueryParams,
3939
include_provider_raw_response: bool = False,
4040
) -> tuple[LLMCallResponse | None, str | None]:
@@ -43,7 +43,7 @@ def execute(
4343
Directly passes the user's config params to provider API along with input.
4444
4545
Args:
46-
completion_config: LLM completion configuration
46+
completion_config: LLM completion configuration, pass params as-is to provider API
4747
query: Query parameters including input and conversation_id
4848
include_provider_raw_response: Whether to include the raw LLM provider response in the output
4949

backend/app/services/llm/providers/openai.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from openai.types.responses.response import Response
66

77
from app.models.llm import (
8-
CompletionConfig,
8+
NativeCompletionConfig,
99
LLMCallResponse,
1010
QueryParams,
1111
LLMOutput,
@@ -30,7 +30,7 @@ def __init__(self, client: OpenAI):
3030

3131
def execute(
3232
self,
33-
completion_config: CompletionConfig,
33+
completion_config: NativeCompletionConfig,
3434
query: QueryParams,
3535
include_provider_raw_response: bool = False,
3636
) -> tuple[LLMCallResponse | None, str | None]:

backend/app/services/llm/providers/registry.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@
1212

1313

1414
class LLMProvider:
15-
OPENAI = "openai"
16-
# Future constants:
17-
# ANTHROPIC = "anthropic"
18-
# GOOGLE = "google"
15+
OPENAI_NATIVE = "openai-native"
16+
# Future constants for native providers:
17+
# CLAUDE_NATIVE = "claude-native"
18+
# GEMINI_NATIVE = "gemini-native"
1919

2020
_registry: dict[str, type[BaseProvider]] = {
21-
OPENAI: OpenAIProvider,
22-
# ANTHROPIC: AnthropicProvider,
23-
# GOOGLE: GoogleProvider,
21+
OPENAI_NATIVE: OpenAIProvider,
22+
# Future native providers:
23+
# CLAUDE_NATIVE: ClaudeProvider,
24+
# GEMINI_NATIVE: GeminiProvider,
2425
}
2526

2627
@classmethod
@@ -45,19 +46,22 @@ def get_llm_provider(
4546
) -> BaseProvider:
4647
provider_class = LLMProvider.get(provider_type)
4748

49+
# e.g., "openai-native" → "openai", "claude-native" → "claude"
50+
credential_provider = provider_type.replace("-native", "")
51+
4852
credentials = get_provider_credential(
4953
session=session,
50-
provider=provider_type,
54+
provider=credential_provider,
5155
project_id=project_id,
5256
org_id=organization_id,
5357
)
5458

5559
if not credentials:
5660
raise ValueError(
57-
f"Credentials for provider '{provider_type}' not configured for this project."
61+
f"Credentials for provider '{credential_provider}' not configured for this project."
5862
)
5963

60-
if provider_type == LLMProvider.OPENAI:
64+
if provider_type == LLMProvider.OPENAI_NATIVE:
6165
if "api_key" not in credentials:
6266
raise ValueError("OpenAI credentials not configured for this project.")
6367
client = OpenAI(api_key=credentials["api_key"])

0 commit comments

Comments
 (0)