| name | synalinks |
| description | Build neuro-symbolic LLM applications with Synalinks framework. Use when working with DataModel, Program, Generator, Module, training LLM pipelines, in-context learning, structured output, JSON operators, Branch/Decision control flow, FunctionCallingAgent, RAG/KAG, or Keras-like LLM workflows. |
Synalinks Framework
Synalinks is an open-source Keras-inspired framework for building neuro-symbolic LLM applications with in-context reinforcement learning.
Core Concepts
- DataModel: Pydantic-style schema defining structured I/O (replaces tensors)
- Module: Computational unit processing JSON data (replaces layers)
- Program: DAG of modules with conditional logic (replaces models)
- Rewards: Guide training (maximize reward, not minimize loss)
- Optimizers: Update prompts/examples via LLM reasoning (no gradients)
Quick Start
import synalinks
import asyncio
class Query(synalinks.DataModel):
query: str = synalinks.Field(description="The user query")
class Answer(synalinks.DataModel):
answer: str = synalinks.Field(description="The answer")
async def main():
lm = synalinks.LanguageModel(model="ollama/mistral")
inputs = synalinks.Input(data_model=Query)
outputs = await synalinks.Generator(
data_model=Answer,
language_model=lm,
)(inputs)
program = synalinks.Program(
inputs=inputs,
outputs=outputs,
name="simple_qa",
description="A simple Q&A program",
)
result = await program(Query(query="What is the capital of France?"))
print(result.prettify_json())
asyncio.run(main())
Four Ways to Build Programs
1. Functional API (Recommended for most cases)
inputs = synalinks.Input(data_model=Query)
outputs = await synalinks.Generator(data_model=Answer, language_model=lm)(inputs)
program = synalinks.Program(inputs=inputs, outputs=outputs)
2. Sequential API (Simple linear chains)
program = synalinks.Sequential([
synalinks.Input(data_model=Query),
synalinks.Generator(data_model=Answer, language_model=lm),
])
3. Subclassing (Advanced custom logic)
class MyProgram(synalinks.Program):
def __init__(self, language_model):
super().__init__()
self.gen = synalinks.Generator(data_model=Answer, language_model=language_model)
async def call(self, inputs, training=False):
return await self.gen(inputs)
def get_config(self):
return {"name": self.name, "language_model": synalinks.saving.serialize_synalinks_object(self.language_model)}
@classmethod
def from_config(cls, config):
lm = synalinks.saving.deserialize_synalinks_object(config.pop("language_model"))
return cls(language_model=lm)
4. Mixed (Functional + Subclassing)
class MyProgram(synalinks.Program):
def __init__(self, language_model):
super().__init__()
self.language_model = language_model
async def build(self, inputs):
outputs = await synalinks.Generator(data_model=Answer, language_model=self.language_model)(inputs)
super().__init__(inputs=inputs, outputs=outputs)
JSON Operators (Circuit-like Logic)
| Operator | Symbol | Behavior |
|---|
| Concatenate | + | Merge fields; raises Exception if either is None |
| Logical And | & | Merge fields; returns None if either is None |
| Logical Or | | | Merge fields; returns non-None value if one is None |
| Logical Xor | ^ | Returns None if both present; otherwise returns the non-None value |
| Contains | in | Check if one DataModel's fields are a subset of another |
combined = x1 + x2
combined = inputs & branch_output
result = branch1_output | branch2_output
guarded = warning ^ inputs
print(Query in (Query + Answer))
print(Query in Answer)
Module Alternatives to Operators
You can also use explicit module classes instead of operators:
merged = await synalinks.And()([b0, b1, b2])
result = await synalinks.Or()([b0, b1, b2])
Control Flow
Parallel Branches (auto-detected)
x1 = await synalinks.Generator(data_model=Answer, language_model=lm)(inputs)
x2 = await synalinks.Generator(data_model=Answer, language_model=lm)(inputs)
Decision Making
decision = await synalinks.Decision(
question="Evaluate query difficulty",
labels=["easy", "difficult"],
language_model=lm,
)(inputs)
Conditional Branching
(easy_answer, hard_answer) = await synalinks.Branch(
question="Evaluate query difficulty",
labels=["easy", "difficult"],
branches=[
synalinks.Generator(data_model=Answer, language_model=lm),
synalinks.Generator(data_model=AnswerWithThinking, language_model=lm),
],
language_model=lm,
return_decision=False,
inject_decision=False,
)(inputs)
final = easy_answer | hard_answer
Key Branch behaviors:
- Non-activated branches return
None (not executed, not just empty)
- Each branch module gets optimized separately during training (specialized modules)
- Labels constrain LLM output - prevents hallucination by enforcing valid choices
Self-Consistency Pattern (Parallel Reasoning)
Use parallel branches with temperature > 0 to generate multiple answers, then merge:
async def build_self_consistency_program(lm):
inputs = synalinks.Input(data_model=Query)
b0 = await synalinks.Generator(data_model=AnswerWithRationale, language_model=lm, temperature=1.0)(inputs)
b1 = await synalinks.Generator(data_model=AnswerWithRationale, language_model=lm, temperature=1.0)(inputs)
b2 = await synalinks.Generator(data_model=AnswerWithRationale, language_model=lm, temperature=1.0)(inputs)
merged = b0 & b1 & b2
outputs = await synalinks.Generator(
data_model=AnswerWithRationale,
language_model=lm,
instructions="Critically analyze the given answers to produce the final answer.",
)(inputs & merged)
return synalinks.Program(inputs=inputs, outputs=outputs)
Input/Output Guards (XOR Pattern)
Use XOR (^) to bypass computation based on conditions:
Input Guard - Block processing if input is invalid:
class InputGuard(synalinks.Module):
"""Block invalid inputs."""
async def call(self, inputs, training=False):
if self._is_blocked(inputs):
return synalinks.ChatMessage(role="assistant", content="Cannot process this request")
return None
async def build_guarded_program(lm):
inputs = synalinks.Input(data_model=synalinks.ChatMessages)
warning = await InputGuard()(inputs)
guarded_inputs = warning ^ inputs
answer = await synalinks.Generator(language_model=lm)(guarded_inputs)
outputs = warning | answer
return synalinks.Program(inputs=inputs, outputs=outputs)
Output Guard - Replace invalid outputs:
async def build_output_guarded_program(lm):
inputs = synalinks.Input(data_model=synalinks.ChatMessages)
answer = await synalinks.Generator(language_model=lm)(inputs)
warning = await OutputGuard()(answer)
outputs = (answer ^ warning) | warning
return synalinks.Program(inputs=inputs, outputs=outputs)
Training Programs
program.compile(
reward=synalinks.rewards.ExactMatch(in_mask=["answer"]),
optimizer=synalinks.optimizers.RandomFewShot(),
metrics=[synalinks.metrics.F1Score(in_mask=["answer"])],
)
history = await program.fit(
x=x_train,
y=y_train,
validation_split=0.2,
epochs=10,
batch_size=32,
callbacks=[synalinks.callbacks.ProgramCheckpoint(filepath="best.json", monitor="val_reward", mode="max")],
)
metrics = await program.evaluate(x=x_test, y=y_test, batch_size=32)
predictions = await program.predict(x_test, batch_size=32)
Built-in Rewards
synalinks.rewards.ExactMatch(in_mask=["field"]) - Exact string match
synalinks.rewards.CosineSimilarity(embedding_model=em, in_mask=["field"]) - Semantic similarity
synalinks.rewards.LMAsJudge(language_model=lm) - LLM-based evaluation
Custom Rewards
@synalinks.saving.register_synalinks_serializable()
async def my_reward(y_true, y_pred):
return 1.0 if y_true.get("answer") == y_pred.get("answer") else 0.0
program.compile(reward=synalinks.rewards.MeanRewardWrapper(fn=my_reward))
Built-in Optimizers
RandomFewShot (Baseline)
synalinks.optimizers.RandomFewShot(
few_shot_learning=False,
nb_min_examples=1,
nb_max_examples=3,
)
Use as baseline. Fast, no extra LLM calls. Only manipulates examples, not prompts.
OMEGA (Advanced Evolutionary Optimizer)
OptiMizEr as Genetic Algorithm - Uses LLM-based mutation/crossover with Dominated Novelty Search for quality-diversity optimization.
synalinks.optimizers.OMEGA(
language_model=lm,
embedding_model=em,
k_nearest_fitter=5,
population_size=10,
mutation_temperature=0.3,
crossover_temperature=0.3,
selection_temperature=0.3,
merging_rate=0.02,
algorithm="dns",
selection="softmax",
few_shot_learning=False,
nb_min_examples=1,
nb_max_examples=3,
instructions=None,
)
When to use OMEGA:
- RandomFewShot isn't achieving target performance
- Need to optimize prompts/instructions, not just examples
- Want diverse solution exploration (quality-diversity)
- Have compute budget for extra LLM calls
Key differences from RandomFewShot:
| Aspect | RandomFewShot | OMEGA |
|---|
| Optimizes | Examples only | Full trainable variables (prompts, code, plans) |
| Generation | Random sampling | LLM mutation + crossover with ChainOfThought |
| Quality-Diversity | No | Yes (Dominated Novelty Search) |
| Extra LLM calls | None | 1+ per batch |
OMEGA Gotchas:
- Must provide BOTH
language_model AND embedding_model
- Temperature must be non-zero (breaks softmax otherwise)
merging_rate is multiplicative: at epoch 10 with default 0.02, crossover is 20%
- Keep
population_size <= 20 for reasonable DNS overhead
Using OMEGA with OpenRouter:
The OpenRouterEmbeddingModel wrapper (see "Using OpenRouter" section) is compatible with OMEGA. It includes automatic string conversion for tree.flatten() output, which may contain non-string leaf values from trainable variables.
from your_openrouter_module import create_openrouter_language_model, OpenRouterEmbeddingModel
lm_optimizer = create_openrouter_language_model("anthropic/claude-3.5-sonnet")
em = OpenRouterEmbeddingModel(
"qwen/qwen3-embedding-8b",
provider={"only": ["nebius"], "allow_fallbacks": False},
)
program.compile(
reward=synalinks.rewards.ExactMatch(in_mask=["answer"]),
optimizer=synalinks.optimizers.OMEGA(
language_model=lm_optimizer,
embedding_model=em,
),
)
See references/training-guide.md for complete OMEGA documentation including DNS algorithm details, all parameters, and advanced usage patterns.
Agents with Tools
@synalinks.utils.register_synalinks_serializable()
async def calculate(expression: str):
"""Calculate mathematical expression."""
return {"result": eval(expression), "log": "Success"}
tools = [synalinks.Tool(calculate)]
outputs = await synalinks.FunctionCallingAgent(
data_model=FinalAnswer,
tools=tools,
language_model=lm,
max_iterations=5,
autonomous=True,
)(inputs)
MCP Tools
mcp_client = synalinks.MultiServerMCPClient({
"math": {"url": "http://localhost:8183/mcp/", "transport": "streamable_http"},
})
tools = await mcp_client.get_tools()
RAG with Knowledge Graph
knowledge_base = synalinks.KnowledgeBase(
uri="memgraph://localhost:7687",
entity_models=[City, Country],
relation_models=[IsCapitalOf],
embedding_model=embedding_model,
)
retriever_output = await synalinks.EntityRetriever(
entity_models=[City, Country],
knowledge_base=knowledge_base,
language_model=lm,
return_inputs=True,
)(inputs)
answer = await synalinks.Generator(
data_model=Answer,
language_model=lm,
instructions=["Answer based on search results"],
)(retriever_output)
Saving and Loading
program.save("my_program.json")
program = synalinks.Program.load("my_program.json")
program.save_variables("my_program.variables.json")
program.load_variables("my_program.variables.json")
Visualization
synalinks.utils.plot_program(
program,
to_folder="output",
show_module_names=True,
show_schemas=True,
show_trainable=True,
)
synalinks.utils.plot_history(history, to_folder="output")
Configuration
synalinks.enable_logging()
synalinks.enable_observability()
synalinks.clear_session()
Program Inspection
print(f"Number of modules: {len(program.modules)}")
module = program.get_module(index=0)
module = program.get_module(name="generator")
print(f"Total variables: {len(program.variables)}")
print(f"Trainable variables: {len(program.trainable_variables)}")
print(program.trainable_variables[0]["instructions"])
program.summary()
Built-in Datasets
(x_train, y_train), (x_test, y_test) = synalinks.datasets.gsm8k.load_data()
InputModel = synalinks.datasets.gsm8k.get_input_data_model()
OutputModel = synalinks.datasets.gsm8k.get_output_data_model()
Important Notes and Gotchas
instructions Parameter
The instructions parameter in Generator MUST be a string, not a list:
synalinks.Generator(
data_model=Answer,
language_model=lm,
instructions="Be concise. Focus on key points. Use examples.",
)
synalinks.Generator(
data_model=Answer,
language_model=lm,
instructions=["Be concise", "Focus on key points"],
)
Model Compatibility Summary
| Provider | Structured Output | Notes |
|---|
| openai/* | Works | Recommended |
| anthropic/* | Works | Uses tool-calling internally |
| ollama/* | Works | Local models |
| mistral/* | Works | |
| gemini/* | Works | |
| groq/* | Works | Requires patch (see below) |
| groq/compound-* | Works | Requires patch, uses json_object mode |
| openrouter/* | Works | Requires patch (see below) |
| Local (LMStudio, vLLM) | Works | Requires model registration (see below) |
Handling None Results
Always check for None results from program execution:
result = await program(input_data)
if result is None:
print("LLM call failed - check API key and model compatibility")
return
Mixed Subclassing Pattern
When using the mixed pattern (subclassing + functional), call super().__init__() TWICE:
class MyProgram(synalinks.Program):
def __init__(self, language_model):
super().__init__()
self.language_model = language_model
async def build(self, inputs):
outputs = await synalinks.Generator(...)(inputs)
super().__init__(inputs=inputs, outputs=outputs)
Using Groq Models
Groq requires a patch to work with Synalinks structured output. The issue is that:
- Synalinks uses tool-calling for Groq, but Groq's tool-calling is unreliable for structured output
ChatMessage serializes with tool_calls field, which Groq rejects
Solution: Patch Synalinks to use proper structured output:
- Regular Groq models (llama-3.1-8b-instant, etc.): Uses
json_schema response format
- Compound models (compound-beta, compound-beta-mini): Uses
json_object mode with schema in prompt
(compound models don't support json_schema)
import os
import copy
import json
import warnings
from functools import wraps
import litellm
import synalinks
from synalinks.src.backend import ChatRole
from synalinks.src.language_models.language_model import LanguageModel
from synalinks.src.utils.nlp_utils import shorten_text
def _clean_messages_for_groq(messages: list) -> list:
"""Remove tool_calls and tool_call_id from messages."""
cleaned = []
for msg in messages:
clean_msg = {"role": msg.get("role"), "content": msg.get("content", "")}
if msg.get("role") == "tool" and msg.get("tool_call_id"):
clean_msg["tool_call_id"] = msg["tool_call_id"]
cleaned.append(clean_msg)
return cleaned
_original_call = None
async def _patched_call(self, messages, schema=None, streaming=False, **kwargs):
"""Patched __call__ that uses json_schema for Groq instead of tool-calling."""
formatted_messages = messages.get_json().get("messages", [])
input_kwargs = copy.deepcopy(kwargs)
schema = copy.deepcopy(schema)
.model.startswith():
formatted_messages = _clean_messages_for_groq(formatted_messages)
schema:
.model.startswith():
kwargs.update({
: {
: ,
: {: , : schema},
}
})
.model.startswith():
kwargs.update({
: [{: , : ,
: {: , : schema.get(),
: schema.get()}}],
: {: , : },
})
.model.startswith() .model.startswith():
kwargs.update({: {: , : {: schema}, : }})
.model.startswith() .model.startswith():
schema:
prop_key, prop_value schema[].items():
prop_value prop_value:
prop_value[]
kwargs.update({: {: , : {: , : , : schema}}})
.model.startswith() .model.startswith() .model.startswith():
kwargs.update({: {: , : {: schema}, : }})
:
ValueError()
.api_base:
kwargs.update({: .api_base})
streaming schema:
streaming =
streaming:
kwargs.update({: })
i (.retry):
:
response_str =
response = litellm.acompletion(model=.model, messages=formatted_messages,
timeout=.timeout, caching=.caching, **kwargs)
(response, ) response._hidden_params:
.last_call_cost = response._hidden_params[]
.last_call_cost :
.cumulated_cost += .last_call_cost
.model.startswith() schema:
response_str = response[][][][][][][]
:
response_str = response[][][][].strip()
schema:
json.loads(response_str)
:
{: ChatRole.ASSISTANT, : response_str, : , : []}
Exception e:
warnings.warn()
asyncio
asyncio.sleep()
.fallback(messages, schema=schema, streaming=streaming, **input_kwargs) .fallback
():
_original_call
_original_call :
_original_call = LanguageModel.__call__
LanguageModel.__call__ = _patched_call
() -> synalinks.LanguageModel:
patch_synalinks_for_groq()
synalinks.LanguageModel(model=, **kwargs)
lm = create_groq_language_model()
lm = create_groq_language_model()
Using OpenRouter
OpenRouter provides access to many models through a unified API. Synalinks requires a patch to recognize OpenRouter as a provider.
Key considerations:
- OpenRouter is OpenAI-compatible, so the patch treats it like OpenAI for structured output
- Provider routing lets you specify which backend provider to use (e.g., DeepInfra, nebius)
- LiteLLM does NOT support OpenRouter embeddings - use direct API calls instead
Solution for LLM completions:
import os
import copy
import json
import warnings
from typing import Any, Dict, Optional
import litellm
import synalinks
from synalinks.src.backend import ChatRole
from synalinks.src.language_models.language_model import LanguageModel
from synalinks.src.utils.nlp_utils import shorten_text
_provider_configs: Dict[int, Dict[str, Any]] = {}
_original_call = None
async def _patched_call_openrouter(self, messages, schema=None, streaming=False, **kwargs):
"""Patched __call__ that handles OpenRouter as OpenAI-compatible."""
formatted_messages = messages.get_json().get("messages", [])
input_kwargs = copy.deepcopy(kwargs)
schema = copy.deepcopy(schema)
if schema:
if self.model.startswith("openrouter"):
if "properties" in schema:
for prop_key, prop_value in schema["properties"].items():
if "$ref" in prop_value prop_value:
prop_value[]
kwargs.update({
: {
: ,
: {: , : , : schema},
}
})
.model.startswith():
kwargs.update({
: [{: , : ,
: {: , : schema.get(),
: schema.get()}}],
: {: , : },
})
.model.startswith() .model.startswith():
kwargs.update({: {: , : {: schema}, : }})
.model.startswith() .model.startswith():
schema:
prop_key, prop_value schema[].items():
prop_value prop_value:
prop_value[]
kwargs.update({: {: , : {: , : , : schema}}})
.model.startswith() .model.startswith() .model.startswith():
kwargs.update({: {: , : {: schema}, : }})
:
ValueError()
.api_base:
kwargs.update({: .api_base})
streaming schema:
streaming =
streaming:
kwargs.update({: })
instance_id = ()
instance_id _provider_configs:
kwargs:
kwargs[] = {}
kwargs[][] = _provider_configs[instance_id]
i (.retry):
:
response_str =
response = litellm.acompletion(model=.model, messages=formatted_messages,
timeout=.timeout, caching=.caching, **kwargs)
(response, ) response._hidden_params:
.last_call_cost = response._hidden_params[]
.last_call_cost :
.cumulated_cost += .last_call_cost
.model.startswith() schema:
response_str = response[][][][][][][]
:
response_str = response[][][][].strip()
schema:
json.loads(response_str)
:
{: ChatRole.ASSISTANT, : response_str, : , : []}
Exception e:
warnings.warn()
asyncio
asyncio.sleep()
.fallback(messages, schema=schema, streaming=streaming, **input_kwargs) .fallback
():
_original_call
_original_call :
_original_call = LanguageModel.__call__
LanguageModel.__call__ = _patched_call_openrouter
() -> synalinks.LanguageModel:
patch_synalinks_for_openrouter()
full_model_name =
lm = synalinks.LanguageModel(model=full_model_name, **kwargs)
provider:
_provider_configs[(lm)] = provider
lm
lm = create_openrouter_language_model()
lm = create_openrouter_language_model(
,
provider={: [], : },
)
Solution for OpenRouter Embeddings:
LiteLLM does not support OpenRouter embeddings. Use direct API calls instead:
import os
import warnings
from typing import Any, Dict, List, Optional
import httpx
OPENROUTER_API_BASE = "https://openrouter.ai/api/v1"
class OpenRouterEmbeddingModel:
"""
OpenRouter embedding model using direct API calls.
LiteLLM doesn't support OpenRouter embeddings, so this bypasses LiteLLM.
Embedding models on OpenRouter are often only available from one provider,
so specifying the provider is recommended.
Args:
model: OpenRouter model name (e.g., "qwen/qwen3-embedding-8b")
api_key: API key. If not provided, uses OPENROUTER_API_KEY env var.
provider: Provider routing (e.g., {"only": ["nebius"], "allow_fallbacks": False})
retry: Number of retries on failure. Default 5.
timeout: Request timeout in seconds. Default 30.
"""
def __init__(
self,
model: str,
api_key: Optional[str] = None,
provider: Optional[Dict[str, Any]] = None,
retry: int = 5,
timeout: float = 30.0,
):
self.model = model
self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY")
self.provider = provider
self.retry = retry
self.timeout = timeout
if not self.api_key:
ValueError()
() -> [[, [[]]]]:
texts = [(t) t texts]
i (.retry):
:
httpx.AsyncClient(timeout=.timeout) client:
payload: [, ] = {: .model, : texts}
.provider:
payload[] = .provider
response = client.post(
,
headers={: , : },
json=payload,
)
response.status_code == :
data = response.json()
vectors = [item.get(, []) item data.get(, [])]
{: vectors}
:
warnings.warn()
Exception e:
warnings.warn()
asyncio
asyncio.sleep()
em = OpenRouterEmbeddingModel(
,
provider={: [], : },
)
result = em([])
result = em([, , ])
Finding available providers:
Use OpenRouter's API to check which providers host a model:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://openrouter.ai/api/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
)
models = response.json().get("data", [])
for model in models:
if "your-model-name" in model.get("id", ""):
print(f"Providers: {model.get('providers', 'N/A')}")
Using Local Models (LMStudio, vLLM, etc.)
When using local OpenAI-compatible servers like LMStudio, you MUST:
- Set a dummy API key (any non-empty string)
- Register the model with LiteLLM before creating the LanguageModel
Required setup for LMStudio:
import os
import litellm
import synalinks
os.environ["OPENAI_API_KEY"] = "lm-studio"
litellm.register_model(
{
"openai/ibm/granite-4-h-tiny": {
"max_tokens": 4096,
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
"litellm_provider": "openai",
"mode": "chat",
}
}
)
lm = synalinks.LanguageModel(
model="openai/ibm/granite-4-h-tiny",
api_base="http://localhost:1234/v1",
)
Why registration is required: LiteLLM tracks API costs internally. For models not in its pricing database, it returns None for costs, which Synalinks cannot handle. Registering the model with zero costs resolves this.
Helper pattern for cleaner code:
def create_lmstudio_language_model(
model_name: str,
api_base: str = "http://localhost:1234/v1",
max_tokens: int = 4096,
**kwargs,
) -> synalinks.LanguageModel:
"""Create a Synalinks LanguageModel configured for LMStudio."""
import os
import litellm
os.environ["OPENAI_API_KEY"] = "lm-studio"
full_model_name = f"openai/{model_name}"
litellm.register_model({
full_model_name: {
"max_tokens": max_tokens,
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
"litellm_provider": "openai",
"mode": "chat",
}
})
return synalinks.LanguageModel(
model=full_model_name,
api_base=api_base,
**kwargs,
)
lm = create_lmstudio_language_model("ibm/granite-4-h-tiny")
References
Read these for detailed information:
- references/api-reference.md - Full API for all classes and methods
- references/data-models.md - DataModel, Field, and schema patterns
- references/modules-catalog.md - All built-in modules (Generator, ChainOfThought, Decision, etc.)
- references/training-guide.md - Rewards, optimizers, callbacks, and training workflows
- references/agents-tools.md - FunctionCallingAgent, Tool, MCP integration
- references/knowledge-base.md - KnowledgeBase, EntityRetriever, TripletRetriever, RAG/KAG