Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions ballerina-interpreter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,75 @@ afmFilePath = "path/to/agent.afm.md"

The AFM file path can also be passed as a command-line argument.

## Model Providers

The model is configured via the `model` block in the AFM frontmatter. Supported providers:

| `provider` | Required fields | Credentials |
|---|---|---|
| _(omitted)_ | — | `WSO2_MODEL_PROVIDER_TOKEN` env var (WSO2 default model) |
| `wso2` | — | `authentication` (bearer) or `WSO2_MODEL_PROVIDER_TOKEN` |
| `openai` | `name` | `authentication` (api-key) |
| `anthropic` | `name` | `authentication` (api-key) |
| `ollama` | `name` | none (local; optional `url`) |
| `gemini` | `name`, `project` | Google ADC or service account key via `GOOGLE_APPLICATION_CREDENTIALS` |

### Vertex AI (Gemini)

`provider: gemini` runs against **Vertex AI**. Vertex mode is selected by the presence of the
`project` field (matching the Python interpreter). No `authentication` block is needed in the
AFM file — credentials are read from the standard `GOOGLE_APPLICATION_CREDENTIALS` environment
variable, which may point at **either** credential format:

- **Application Default Credentials** (`authorized_user`) — produced by
`gcloud auth application-default login`. The interpreter maps these to the connector's
OAuth2 refresh-token flow. This is the quickest path for local development and is the same
file the Python interpreter uses.
- **Service account key** (`service_account`) — a downloaded JSON key. Recommended for
production / CI.

```yaml
model:
provider: gemini
name: gemini-2.5-flash # bare names are sent to the "google" publisher
project: your-gcp-project-id
location: us-central1 # optional, defaults to us-central1
```

**Local development (ADC):**

```bash
gcloud auth application-default login
export GOOGLE_APPLICATION_CREDENTIALS="$HOME/.config/gcloud/application_default_credentials.json"
bal run -- agent-vertex.afm.md
```

**Production (service account):**

```bash
gcloud iam service-accounts create afm-vertex --project YOUR_PROJECT_ID
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:afm-vertex@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/aiplatform.user"
gcloud iam service-accounts keys create sa-key.json \
--iam-account=afm-vertex@YOUR_PROJECT_ID.iam.gserviceaccount.com
export GOOGLE_APPLICATION_CREDENTIALS="$(pwd)/sa-key.json"
```

**Docker** — mount the credentials file (read-only) and point the env var at it inside the
container. Using your ADC file (same as the Python interpreter):

```bash
docker run -it --rm \
-v $(pwd)/agent-vertex.afm.md:/app/agent.afm.md \
-v $HOME/.config/gcloud/application_default_credentials.json:/tmp/adc.json:ro \
-e GOOGLE_APPLICATION_CREDENTIALS=/tmp/adc.json \
afm-ballerina-interpreter /app/agent.afm.md
```

In a managed environment (e.g. GKE / Cloud Run), prefer an attached service account
(workload identity) instead of mounting a credentials file.

## Running with Docker

```bash
Expand Down
69 changes: 69 additions & 0 deletions ballerina-interpreter/agent.bal
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
import afm_ballerina.everit.validator;

import ballerina/ai;
import ballerina/io;
import ballerina/log;
import ballerina/os;
import ballerina/http;
import ballerinax/ai.anthropic;
import ballerinax/ai.openai;
import ballerinax/ai.ollama;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import ballerinax/ai.ollama;
import ballerinax/ai.ollama;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I added a new line

import ballerinax/ai.googleapis.vertex;

function createAgent(AFMRecord afmRecord, string afmFileDir) returns ai:Agent|error {
AFMRecord {metadata, role, instructions} = afmRecord;
Expand Down Expand Up @@ -127,10 +130,76 @@ function getModel(Model? model) returns ai:ModelProvider|error {
model.url ?: "https://api.anthropic.com/v1"
);
}
"ollama" => {
string url = model.url ?: "http://localhost:11434";
return new ollama:ModelProvider(check name.ensureType(), url);
}
"gemini" => {
return getGeminiModel(model, name);
}
}
return error(string `Model provider: ${provider} not yet supported`);
}

// Standard Google environment variable holding the path to a service account JSON key.
const GOOGLE_APP_CREDENTIALS_ENV = "GOOGLE_APPLICATION_CREDENTIALS";
const DEFAULT_VERTEX_LOCATION = "us-central1";
//(e.g. "gemini-2.5-flash" -> "google/gemini-2.5-flash").
const DEFAULT_MODEL_PUBLISHER = "google";


function getGeminiModel(Model model, string name) returns ai:ModelProvider|error {
string? project = model.project;
if project is () {
return error("Vertex AI requires the 'project' field to be set in the model block " +
"(AI Studio Gemini is not yet supported by the Ballerina interpreter)");
}

string? credentialsPath = os:getEnv(GOOGLE_APP_CREDENTIALS_ENV);
if credentialsPath is () || credentialsPath.trim() == "" {
return error(string `Vertex AI authentication requires Google credentials. Set the ` +
string `'${GOOGLE_APP_CREDENTIALS_ENV}' environment variable to the path of a service account ` +
string `JSON key file or a gcloud Application Default Credentials file.`);
}

vertex:VertexAiAuth auth = check resolveVertexAuth(credentialsPath);

string location = model.location ?: DEFAULT_VERTEX_LOCATION;
string qualifiedModel = name.includes("/") ? name : string `${DEFAULT_MODEL_PUBLISHER}/${name}`;

vertex:ModelProvider|error vertexModel = new (auth, project, qualifiedModel, location = location);
if vertexModel is error {
return error(string `Failed to initialize the Vertex AI model provider: ${vertexModel.message()}`, vertexModel);
}
return vertexModel;
}

function resolveVertexAuth(string credentialsPath) returns vertex:VertexAiAuth|error {
json|error credsJson = io:fileReadJson(credentialsPath);
if credsJson is error {
return error(string `Unable to read Google credentials file at '${credentialsPath}': ${credsJson.message()}`, credsJson);
}

map<json>|error creds = credsJson.ensureType();
if creds is error {
return error(string `Google credentials file at '${credentialsPath}' is not a valid JSON object`);
}

if creds["type"] == "authorized_user" {
json clientId = creds["client_id"];
json clientSecret = creds["client_secret"];
json refreshToken = creds["refresh_token"];
if clientId !is string || clientSecret !is string || refreshToken !is string {
return error("Authorized-user (ADC) credentials must contain string 'client_id', " +
"'client_secret', and 'refresh_token' fields");
}
vertex:OAuth2RefreshConfig oauth2Config = {clientId, clientSecret, refreshToken};
return oauth2Config;
}

return credentialsPath;
}

const DEFAULT_SESSION_ID = "sessionId";

isolated function runAgent(ai:Agent agent, json payload, map<json>? inputSchema = (),
Expand Down
48 changes: 48 additions & 0 deletions ballerina-interpreter/tests/agent_test.bal

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add an error test for Gemini also.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error test for gemini is already added before. (332 - 341 Lines)

Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
// specific language governing permissions and limitations
// under the License.

import ballerina/ai;
import ballerina/http;
import ballerina/io;
import ballerina/lang.runtime;
import ballerina/os;
import ballerina/test;

@test:Config
Expand Down Expand Up @@ -410,3 +412,49 @@ function testArrayOutputSchemaInvalidResponse() returns error? {
// Should return 500 error due to schema validation failure
test:assertEquals(response.statusCode, 500, "Should return 500 for schema validation failure");
}

@test:Config
function testGetModelGeminiMissingProject() returns error? {
Model model = {
provider: "gemini",
name: "gemini-2.5-flash"
};
var result = getModel(model);
test:assertTrue(result is error);
test:assertTrue((<error>result).message().includes("'project'"));
}

@test:Config
function testGetModelGeminiMissingCredentials() returns error? {
check os:unsetEnv(GOOGLE_APP_CREDENTIALS_ENV);
Model model = {
provider: "gemini",
name: "gemini-2.5-flash",
project: "test-project",
location: "us-central1"
};
var result = getModel(model);
test:assertTrue(result is error);
test:assertTrue((<error>result).message().includes(GOOGLE_APP_CREDENTIALS_ENV));
}

@test:Config
function testGetModelOllamaLocalLoopback() returns error? {
Model model = {
provider: "ollama",
name: "llama3"
};
var result = getModel(model);
test:assertTrue(result is ai:ModelProvider);
}

@test:Config
function testGetModelOllamaCustomEndpoint() returns error? {
Model model = {
provider: "ollama",
name: "mistral",
url: "http://192.168.1.15:11434"
};
var result = getModel(model);
test:assertTrue(result is ai:ModelProvider);
}
2 changes: 2 additions & 0 deletions ballerina-interpreter/types.bal
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ type Model record {|
string name?;
string provider?;
string url?;
string project?;
string location?;
ClientAuthentication authentication?;
|};

Expand Down
2 changes: 1 addition & 1 deletion python-interpreter/packages/afm-core/src/afm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def validate_type_fields(self) -> Self:


class Model(BaseModel):
model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="allow")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep Model strict and rely on the explicit Vertex fields.

Changing Model to extra="allow" broadens the AFM schema from rejecting unknown keys to silently accepting them. In this PR, the downstream Gemini path only reads the explicit project and location fields, so relaxing validation is not needed for the new provider support and makes frontmatter typos harder to catch. Cross-file evidence: python-interpreter/packages/afm-langchain/src/afm_langchain/providers.py:125-160 only consumes those explicit fields.

Suggested change
 class Model(BaseModel):
-    model_config = ConfigDict(extra="allow")
+    model_config = ConfigDict(extra="forbid")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="forbid")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python-interpreter/packages/afm-core/src/afm/models.py` at line 60, The Model
class has been relaxed by setting model_config = ConfigDict(extra="allow"),
which permits unknown keys and hides frontmatter typos; revert this to keep
Model strict by removing or changing the model_config to the default (no
extra="allow") so only the explicit Vertex fields (e.g., project, location) are
accepted; update the Model class definition (inspect the model_config variable
and ConfigDict usage) to restore strict validation and rely on the explicit
fields consumed downstream (see providers that read project and location).


name: str | None = None
provider: str | None = None
Expand Down
2 changes: 2 additions & 0 deletions python-interpreter/packages/afm-langchain/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ dependencies = [
"langchain-anthropic>=1.3.1",
"mcp>=1.26.0",
"langchain-mcp-adapters>=0.2.1",
"langchain-google-genai>=4.2.3",
"langchain-ollama>=1.1.0",
]

[project.entry-points."afm.runner"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,23 @@
if TYPE_CHECKING:
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_ollama import ChatOllama


DEFAULT_OPENAI_MODEL = "gpt-4o"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5"
DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
DEFAULT_OLLAMA_MODEL = "llama3"

DEFAULT_OPENAI_URL = "https://api.openai.com/v1"
DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com"
DEFAULT_OLLAMA_URL = "http://localhost:11434"

# Environment variable names for API keys
OPENAI_API_KEY_ENV = "OPENAI_API_KEY"
ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"
GOOGLE_API_KEY_ENV = "GOOGLE_API_KEY"


def create_model_provider(afm_model: Model | None = None) -> BaseChatModel:
Expand All @@ -51,6 +57,10 @@ def create_model_provider(afm_model: Model | None = None) -> BaseChatModel:
return _create_openai_model(afm_model)
case "anthropic":
return _create_anthropic_model(afm_model)
case "gemini":
return _create_gemini_model(afm_model)
case "ollama":
return _create_ollama_model(afm_model)
case _:
raise ProviderError(f"Unsupported provider: {provider}", provider=provider)

Expand Down Expand Up @@ -112,6 +122,66 @@ def _create_anthropic_model(afm_model: Model) -> ChatAnthropic:

return ChatAnthropic(**kwargs)

def _create_gemini_model(afm_model: Model) -> ChatGoogleGenerativeAI:
try:
from langchain_google_genai import ChatGoogleGenerativeAI
except ImportError as e:
raise ProviderError(
"langchain-google-genai package is required for Gemini models. "
"Install it with: pip install langchain-google-genai",
provider="gemini",
) from e

model_name = afm_model.name if afm_model.name else DEFAULT_GEMINI_MODEL
base_url = afm_model.url if afm_model.url else None

kwargs: dict = {
"model": model_name,
}

if base_url:
kwargs["base_url"] = base_url

extra_attrs = afm_model.model_extra or {}
project = extra_attrs.get("project")
location = extra_attrs.get("location")

if project:
kwargs["project"] = project
kwargs["vertexai"] = True
if location:
kwargs["location"] = location

if not project or afm_model.authentication:
api_key = _get_api_key(
afm_model.authentication,
GOOGLE_API_KEY_ENV,
"gemini",
)
if api_key:
kwargs["api_key"] = api_key

return ChatGoogleGenerativeAI(**kwargs)

def _create_ollama_model(afm_model: Model) -> ChatOllama:
try:
from langchain_ollama import ChatOllama
except ImportError as e:
raise ProviderError(
"langchain-ollama package is required for Ollama models. "
"Install it with: pip install langchain-ollama",
provider="ollama",
) from e

model_name = afm_model.name if afm_model.name else DEFAULT_OLLAMA_MODEL
base_url = afm_model.url if afm_model.url else DEFAULT_OLLAMA_URL

kwargs: dict = {
"model": model_name,
"base_url": base_url,
}

return ChatOllama(**kwargs)

def _get_api_key(
auth: ClientAuthentication | None,
Expand Down
Loading