Skip to content

Commit 61b7062

Browse files
committed
feat(ai): integrate Gemma 4 with Native Function Calling and multi-backend support
- Add GemmaModel enum (2B/4B/26B/31B) and AIBackend enum (Google AI Studio/LM Studio/Ollama) - Implement GEMMA_TOOLS for Native Function Calling (analyze_schema, generate_column_values) - Add _try_tool_calling() with graceful fallback to JSON mode then plain text - Add LM Studio backend support for local Edge deployment - Add 3 MCP tools: sqlseed_gemma4_analyze, sqlseed_gemma4_agent_fill, sqlseed_list_gemma_models - Add google-generativeai dependency
1 parent a01ad6c commit 61b7062

6 files changed

Lines changed: 552 additions & 108 deletions

File tree

plugins/mcp-server-sqlseed/src/mcp_server_sqlseed/server.py

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
import yaml
99
from mcp.server.fastmcp import FastMCP
1010

11-
from sqlseed.config.models import GeneratorConfig
11+
from sqlseed.config.models import ColumnConfig, GeneratorConfig
1212
from sqlseed.core.orchestrator import DataOrchestrator
1313

1414
try:
1515
from sqlseed_ai.analyzer import SchemaAnalyzer
16-
from sqlseed_ai.config import AIConfig
16+
from sqlseed_ai.config import AIBackend, AIConfig, GemmaModel
1717
from sqlseed_ai.refiner import AiConfigRefiner, AISuggestionFailedError
1818

1919
_AI_AVAILABLE = True
@@ -188,3 +188,155 @@ def sqlseed_execute_fill(
188188
"elapsed": result.elapsed,
189189
"errors": result.errors,
190190
}
191+
192+
193+
@mcp.tool()
194+
def sqlseed_gemma4_analyze(
195+
db_path: str,
196+
table_name: str,
197+
model: str | None = None,
198+
backend: str | None = None,
199+
) -> dict[str, Any]:
200+
"""Analyze a database table schema using Gemma 4 with native function calling.
201+
202+
This tool leverages Gemma 4's built-in tool use capability to analyze
203+
table structure and recommend data generation configurations. It demonstrates
204+
Gemma 4's Native Function Calling feature for the AI Agent track.
205+
206+
Supported backends: google_ai_studio (default), ollama, openai_compat.
207+
Supported models: gemma-4-26b-it (default), gemma-4-31b-it, gemma-4-4b-it, gemma-4-2b-it.
208+
"""
209+
if not _AI_AVAILABLE:
210+
return {"error": "sqlseed-ai plugin not installed. Install with: pip install sqlseed-ai"}
211+
212+
db_path = _validate_db_path(db_path)
213+
with DataOrchestrator(db_path) as orch:
214+
_validate_table_name(table_name, orch.get_table_names())
215+
schema_ctx = orch.get_schema_context(table_name)
216+
217+
# Build AIConfig with Gemma 4 defaults
218+
ai_config = AIConfig.from_env()
219+
if model:
220+
ai_config.model = model
221+
if backend:
222+
try:
223+
ai_config.backend = AIBackend(backend)
224+
except ValueError:
225+
return {"error": f"Invalid backend: {backend}. Use: google_ai_studio, ollama, openai_compat"}
226+
ai_config.resolve_model()
227+
228+
analyzer = SchemaAnalyzer(config=ai_config)
229+
result = analyzer.analyze_table_from_ctx(**_serialize_schema_context(schema_ctx))
230+
231+
if result is None:
232+
return {"error": "Gemma 4 analysis returned no result. Check API key and model availability."}
233+
234+
return {
235+
"model": ai_config.model,
236+
"backend": ai_config.backend.value,
237+
"table_name": table_name,
238+
"config": result,
239+
}
240+
241+
242+
@mcp.tool()
243+
def sqlseed_gemma4_agent_fill(
244+
db_path: str,
245+
table_name: str,
246+
count: int = 1000,
247+
model: str | None = None,
248+
backend: str | None = None,
249+
max_retries: int = 3,
250+
) -> dict[str, Any]:
251+
"""End-to-end AI Agent: Gemma 4 analyzes schema → generates config → fills data.
252+
253+
This is a complete Agent workflow that demonstrates Gemma 4's Native Function
254+
Calling capability for the AI Agent track:
255+
1. Inspect schema (Tool Calling: analyze_schema)
256+
2. Generate data configuration (self-correction loop)
257+
3. Execute data fill
258+
259+
The agent uses Gemma 4's tool use to understand schema semantics and
260+
produce appropriate data generation rules automatically.
261+
"""
262+
if not _AI_AVAILABLE:
263+
return {"error": "sqlseed-ai plugin not installed. Install with: pip install sqlseed-ai"}
264+
265+
db_path = _validate_db_path(db_path)
266+
267+
# Step 1: AI analysis with self-correction
268+
ai_config = AIConfig.from_env()
269+
if model:
270+
ai_config.model = model
271+
if backend:
272+
try:
273+
ai_config.backend = AIBackend(backend)
274+
except ValueError:
275+
return {"error": f"Invalid backend: {backend}. Use: google_ai_studio, ollama, openai_compat"}
276+
ai_config.resolve_model()
277+
278+
analyzer = SchemaAnalyzer(config=ai_config)
279+
refiner = AiConfigRefiner(analyzer, db_path)
280+
281+
try:
282+
ai_result = refiner.generate_and_refine(
283+
table_name=table_name,
284+
max_retries=max_retries,
285+
)
286+
except AISuggestionFailedError as e:
287+
return {"error": f"AI suggestion failed: {e}", "model": ai_config.model}
288+
except (ValueError, RuntimeError, OSError) as e:
289+
return {"error": f"Error: {e}", "model": ai_config.model}
290+
291+
if not ai_result:
292+
return {"error": "No AI suggestions available", "model": ai_config.model}
293+
294+
# Step 2: Execute fill with AI-generated config
295+
with DataOrchestrator(db_path) as orch:
296+
_validate_table_name(table_name, orch.get_table_names())
297+
298+
column_configs = [ColumnConfig(**c) for c in ai_result.get("columns", [])]
299+
result = orch.fill_table(
300+
table_name=table_name,
301+
count=count,
302+
column_configs=column_configs,
303+
)
304+
305+
return {
306+
"model": ai_config.model,
307+
"backend": ai_config.backend.value,
308+
"table_name": result.table_name,
309+
"count": result.count,
310+
"elapsed": result.elapsed,
311+
"errors": result.errors,
312+
"ai_config": ai_result,
313+
}
314+
315+
316+
@mcp.tool()
317+
def sqlseed_list_gemma_models() -> dict[str, Any]:
318+
"""List available Gemma 4 model variants with descriptions.
319+
320+
Returns information about all supported Gemma 4 models,
321+
including recommended use cases for each variant.
322+
"""
323+
models = []
324+
for member in GemmaModel:
325+
models.append({
326+
"id": member.value,
327+
"display_name": member.display_name,
328+
})
329+
330+
backends = [
331+
{"id": "google_ai_studio", "description": "Google AI Studio API (free tier available, recommended)"},
332+
{"id": "lm_studio", "description": "LM Studio local deployment (http://127.0.0.1:1234, GUI-based)"},
333+
{"id": "ollama", "description": "Ollama local deployment (offline, CLI-based)"},
334+
{"id": "openai_compat", "description": "Any OpenAI-compatible API endpoint"},
335+
]
336+
337+
return {
338+
"models": models,
339+
"backends": backends,
340+
"default_model": "gemma-4-26b-it",
341+
"default_backend": "google_ai_studio",
342+
}

plugins/sqlseed-ai/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ classifiers = [
2222
"Programming Language :: Python :: 3.13",
2323
]
2424
dependencies = [
25-
"sqlseed>=0.1.0,<2",
25+
"sqlseed>=0.0.1",
2626
"openai>=1.0",
27+
"google-generativeai>=0.8",
2728
]
2829

2930
[project.urls]

plugins/sqlseed-ai/src/sqlseed_ai/_client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,22 @@ def get_openai_client(config: Any | None = None) -> Any:
1414
if config is None:
1515
config = AIConfig.from_env()
1616

17+
# Use the new to_openai_kwargs() for unified resolution
18+
if isinstance(config, AIConfig):
19+
kwargs = config.to_openai_kwargs()
20+
logger.info("Creating OpenAI client", **{"backend": config.backend.value, "base_url": kwargs["base_url"]})
21+
return OpenAI(**kwargs)
22+
23+
# Legacy fallback for non-AIConfig objects
1724
api_key = config.api_key if hasattr(config, "api_key") else None
1825
base_url = config.base_url if hasattr(config, "base_url") else None
1926
timeout = config.timeout if hasattr(config, "timeout") else 60.0
2027

2128
if not api_key:
22-
raise ValueError("AI API key not configured. Set SQLSEED_AI_API_KEY or OPENAI_API_KEY environment variable.")
29+
raise ValueError(
30+
"AI API key not configured. "
31+
"Set GOOGLE_API_KEY, SQLSEED_AI_API_KEY, or OPENAI_API_KEY environment variable. "
32+
"For Ollama, set SQLSEED_AI_BACKEND=ollama."
33+
)
2334

2435
return OpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
Lines changed: 69 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,98 @@
11
from __future__ import annotations
22

3-
import json
4-
import time
5-
import urllib.error
6-
import urllib.request
7-
from typing import Any
3+
from sqlseed_ai.config import AIBackend, GemmaModel
84

95
from sqlseed._utils.logger import get_logger
106

117
logger = get_logger(__name__)
128

13-
_CACHE: dict[str, Any] = {
14-
"model": None,
15-
"expires_at": 0.0,
16-
"available_models": [],
9+
# ── Gemma 4 model selection priority ────────────────────────────────
10+
# Ordered by capability: 26B MoE (best balance) > 31B Dense > 4B > 2B
11+
_GEMMA_MODEL_PRIORITY: list[GemmaModel] = [
12+
GemmaModel.GEMMA_4_26B,
13+
GemmaModel.GEMMA_4_31B,
14+
GemmaModel.GEMMA_4_4B,
15+
GemmaModel.GEMMA_4_2B,
16+
]
17+
18+
# Map backend to preferred model size
19+
_BACKEND_DEFAULT_MODEL: dict[AIBackend, GemmaModel] = {
20+
AIBackend.GOOGLE_AI_STUDIO: GemmaModel.GEMMA_4_26B,
21+
AIBackend.LM_STUDIO: GemmaModel.GEMMA_4_4B, # local inference, prefer smaller
22+
AIBackend.OLLAMA: GemmaModel.GEMMA_4_4B, # smaller for local inference
23+
AIBackend.OPENAI_COMPAT: GemmaModel.GEMMA_4_26B,
1724
}
1825

19-
_CACHE_TTL = 3600
2026

21-
_OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
27+
def select_gemma_model(
28+
backend: AIBackend = AIBackend.GOOGLE_AI_STUDIO,
29+
prefer_small: bool = False,
30+
) -> str:
31+
"""Select the best Gemma 4 model for the given backend.
2232
33+
Args:
34+
backend: The LLM backend provider.
35+
prefer_small: If True, prefer smaller models (useful for Edge/local).
2336
24-
def _fetch_available_free_models() -> list[str]:
25-
try:
26-
req = urllib.request.Request(_OPENROUTER_MODELS_URL)
27-
with urllib.request.urlopen(req, timeout=10) as resp:
28-
data = json.loads(resp.read().decode())
29-
except (OSError, json.JSONDecodeError) as e:
30-
logger.warning("Failed to fetch OpenRouter models, using fallback", error=str(e))
31-
return []
37+
Returns:
38+
The model identifier string.
39+
"""
40+
if prefer_small or backend in (AIBackend.OLLAMA, AIBackend.LM_STUDIO):
41+
# For local inference (Ollama/LM Studio), prefer smaller models
42+
model = GemmaModel.GEMMA_4_4B
43+
logger.info("Selected compact Gemma 4 model for local inference", model=model.value)
44+
return model.value
3245

33-
models_info = []
34-
for model in data.get("data", []):
35-
pricing = model.get("pricing", {})
36-
if pricing.get("prompt") != "0" or pricing.get("completion") != "0":
37-
continue
46+
model = _BACKEND_DEFAULT_MODEL.get(backend, GemmaModel.GEMMA_4_26B)
47+
logger.info("Selected Gemma 4 model", model=model.value, backend=backend.value)
48+
return model.value
3849

39-
if model.get("expiration_date") is not None:
40-
continue
4150

42-
arch = model.get("architecture", {})
43-
if "text" not in arch.get("input_modalities", []):
44-
continue
45-
if "text" not in arch.get("output_modalities", []):
46-
continue
51+
def select_next_gemma_model(failed_model: str) -> str | None:
52+
"""Select the next smaller Gemma 4 model as fallback.
4753
48-
supported = model.get("supported_parameters", [])
49-
if "response_format" not in supported:
50-
continue
54+
Args:
55+
failed_model: The model that failed.
5156
52-
models_info.append({"id": model["id"], "created": model.get("created", 0)})
57+
Returns:
58+
The next model in the priority list, or None if all exhausted.
59+
"""
60+
for i, m in enumerate(_GEMMA_MODEL_PRIORITY):
61+
if m.value == failed_model and i + 1 < len(_GEMMA_MODEL_PRIORITY):
62+
next_model = _GEMMA_MODEL_PRIORITY[i + 1]
63+
logger.info(
64+
"Falling back to smaller Gemma 4 model",
65+
from_model=failed_model,
66+
to_model=next_model.value,
67+
)
68+
return next_model.value
5369

54-
models_info.sort(key=lambda x: x["created"], reverse=True)
55-
return [m["id"] for m in models_info]
70+
logger.warning("No more Gemma 4 models available for fallback", failed_model=failed_model)
71+
return None
5672

5773

58-
def _update_cache(model: str) -> None:
59-
_CACHE["model"] = model
60-
_CACHE["expires_at"] = time.time() + _CACHE_TTL
74+
def get_available_gemma_models() -> list[dict[str, str]]:
75+
"""Return list of available Gemma 4 models with display info."""
76+
return [
77+
{"id": m.value, "display_name": m.display_name}
78+
for m in _GEMMA_MODEL_PRIORITY
79+
]
6180

6281

63-
def select_best_free_model() -> str:
64-
if _CACHE["model"] is not None and time.time() < _CACHE["expires_at"]:
65-
return str(_CACHE["model"])
66-
67-
available = _fetch_available_free_models()
68-
_CACHE["available_models"] = available
82+
# ── Legacy compatibility ─────────────────────────────────────────────
83+
# These functions maintain backward compatibility with code that
84+
# referenced the old OpenRouter-based model selector.
6985

70-
if available:
71-
best = available[0]
72-
_update_cache(best)
73-
logger.info(
74-
"Auto-selected newest free model from OpenRouter",
75-
model=best,
76-
available_count=len(available),
77-
)
78-
return best
79-
80-
fallback = "openrouter/free"
81-
logger.warning("No free models without expiration could be fetched, using hardcoded fallback", model=fallback)
82-
_update_cache(fallback)
83-
logger.info("Using fallback free model", model=fallback)
84-
return fallback
86+
def select_best_free_model() -> str:
87+
"""Legacy compat: returns the default Gemma 4 model."""
88+
return select_gemma_model()
8589

8690

8791
def select_next_free_model(failed_model: str) -> str | None:
88-
available: list[str] = _CACHE.get("available_models", [])
89-
if not available:
90-
available = _fetch_available_free_models()
91-
_CACHE["available_models"] = available
92-
93-
idx = -1
94-
for i, m in enumerate(available):
95-
if m == failed_model:
96-
idx = i
97-
break
98-
99-
if idx == -1 or idx + 1 >= len(available):
100-
return None
101-
102-
next_model = available[idx + 1]
103-
_update_cache(next_model)
104-
logger.info("Falling back to next free model", from_model=failed_model, to_model=next_model)
105-
return next_model
92+
"""Legacy compat: returns the next Gemma 4 model as fallback."""
93+
return select_next_gemma_model(failed_model)
10694

10795

10896
def clear_cache() -> None:
109-
_CACHE["model"] = None
110-
_CACHE["expires_at"] = 0.0
111-
_CACHE["available_models"] = []
97+
"""Legacy compat: no-op, Gemma models don't need cache."""
98+
pass

0 commit comments

Comments
 (0)