Skip to content
Merged
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
15 changes: 11 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# ─── LLM provider (server-side only) ──────────────────────────────────────────
# Pick one provider. If LLM_PROVIDER is omitted, Chainly auto-detects it from
# whichever key below is set.
# openai | anthropic | google | openai-compatible
# Default: Vercel AI Gateway with a free model (nvidia/nemotron-3.5-lightning-free).
# Create a key at https://vercel.com/dashboard → AI Gateway → API Keys.
# On Vercel deployments the OIDC token is used automatically, so no key is needed.
AI_GATEWAY_API_KEY=

# Optional: use another provider instead. If LLM_PROVIDER is omitted, Chainly
# auto-detects it from whichever key is set (AI Gateway first).
# gateway | openai | anthropic | google | openai-compatible
LLM_PROVIDER=
# Optional model override (defaults: gpt-5-mini, claude-sonnet-5-5, gemini-2.5-flash)
# Optional model override. Defaults: nvidia/nemotron-3.5-lightning-free (gateway),
# gpt-5-mini, claude-sonnet-5-5, gemini-2.5-flash. On the gateway, use any
# "creator/model" ID, e.g. reflection/beam-free or anthropic/claude-sonnet-5-5.
LLM_MODEL=

OPENAI_API_KEY=
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ You need **Node.js 22+** and **pnpm 10** (`corepack enable` installs the right v
git clone https://github.com/CSEC-Blockchain/Chainly.git
cd Chainly
pnpm install
cp .env.example .env.local # then add ONE LLM key
cp .env.example .env.local # then add AI_GATEWAY_API_KEY
pnpm dev # http://localhost:3000
```

The app works without RPC or explorer keys (it uses public Sepolia endpoints), but chat needs an LLM key. For a free option, see the Groq / OpenAI-compatible setup in the [README](README.md#quick-start). Get Sepolia test ETH from the [faucet](https://cloud.google.com/application/web3/faucet/ethereum/sepolia) and connect MetaMask on **Ethereum Sepolia**.
The app works without RPC or explorer keys (it uses public Sepolia endpoints), but chat needs an LLM key. By default it uses the Vercel AI Gateway with a free model, so an `AI_GATEWAY_API_KEY` is all you need; other providers are covered in the [README](README.md#quick-start). Get Sepolia test ETH from the [faucet](https://cloud.google.com/application/web3/faucet/ethereum/sepolia) and connect MetaMask on **Ethereum Sepolia**.

> Never commit `.env.local`, API keys, private keys or seed phrases. `.env*` is git-ignored except for `.env.example`.

Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,26 @@ Chainly is an AI agent for **Ethereum Sepolia** (chain ID `11155111`). You ask q

```bash
pnpm install
cp .env.example .env.local # add ONE LLM key
cp .env.example .env.local # add your AI Gateway key
pnpm dev # http://localhost:3000
```

Minimum `.env.local`:
By default Chainly uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) with the free `nvidia/nemotron-3.5-lightning-free` model. Minimum `.env.local`:

```bash
AI_GATEWAY_API_KEY=... # Vercel dashboard → AI Gateway → API Keys
```

On Vercel deployments no key is needed: the gateway authenticates with the project's OIDC token. To try another gateway model, set `LLM_MODEL` (for example `reflection/beam-free`, or a paid one like `anthropic/claude-sonnet-5-5`).

To use a provider directly instead, set its key (and optionally `LLM_PROVIDER`):

```bash
LLM_PROVIDER=openai # openai | anthropic | google | openai-compatible
OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY
```

For a free option, use any OpenAI-compatible provider (Groq, OpenRouter, Ollama):
Any OpenAI-compatible provider (Groq, OpenRouter, Ollama) works too:

```bash
LLM_PROVIDER=openai-compatible
Expand Down Expand Up @@ -172,7 +181,7 @@ The tests cover:

## Troubleshooting

- **"LLM not configured" notice:** set a key in `.env.local` and restart.
- **"LLM not configured" notice:** set `AI_GATEWAY_API_KEY` (or another provider's key) in `.env.local` and restart.
- **RPC errors:** public endpoints are rate limited; set `SEPOLIA_RPC_URL`.
- **Explorer rate limit:** add a free `ETHERSCAN_API_KEY`.
- **Wrong network:** use the **Switch to Sepolia** button in the header.
49 changes: 28 additions & 21 deletions src/lib/agent/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,35 @@ import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogle } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
import { createGateway, type LanguageModel } from "ai";

/**
* Configurable LLM provider. Pick one with LLM_PROVIDER, or let Chainly
* auto-detect from whichever API key is present. All keys stay server-side.
* Configurable LLM provider. Defaults to the Vercel AI Gateway with a free
* model. Pick another with LLM_PROVIDER, or let Chainly auto-detect from
* whichever API key is present. All keys stay server-side.
*/

export type ProviderId = "openai" | "anthropic" | "google" | "openai-compatible";
export type ProviderId = "gateway" | "openai" | "anthropic" | "google" | "openai-compatible";

export const DEFAULT_PROVIDER: ProviderId = "gateway";

const DEFAULT_MODELS: Record<ProviderId, string> = {
// Free on AI Gateway (served by NVIDIA), supports tool calling.
gateway: "nvidia/nemotron-3.5-lightning-free",
openai: "gpt-5-mini",
anthropic: "claude-sonnet-5-5",
google: "gemini-2.5-flash",
"openai-compatible": "llama-3.3-70b-versatile",
};

const KEY_ENV: Record<ProviderId, string> = {
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
google: "GOOGLE_GENERATIVE_AI_API_KEY",
"openai-compatible": "OPENAI_COMPATIBLE_BASE_URL",
/** Env vars that configure each provider; any one of them is enough. Order = auto-detect priority. */
const KEY_ENV: Record<ProviderId, string[]> = {
// On Vercel deployments the OIDC token is injected automatically, so no key is needed.
gateway: ["AI_GATEWAY_API_KEY", "VERCEL_OIDC_TOKEN"],
openai: ["OPENAI_API_KEY"],
anthropic: ["ANTHROPIC_API_KEY"],
google: ["GOOGLE_GENERATIVE_AI_API_KEY"],
"openai-compatible": ["OPENAI_COMPATIBLE_BASE_URL"],
};

export type ModelConfig =
Expand All @@ -41,28 +49,27 @@ export function resolveModelConfig(env: Record<string, string | undefined> = pro
};
}

const hasKey = (p: ProviderId) => KEY_ENV[p].some((name) => Boolean(env[name]));
const provider =
explicit ?? (Object.keys(KEY_ENV) as ProviderId[]).find((p) => Boolean(env[KEY_ENV[p]]));

if (!provider) {
return {
configured: false,
provider: null,
modelId: null,
reason: "No LLM provider is configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY or OPENAI_COMPATIBLE_BASE_URL in .env.local.",
};
}
explicit || (Object.keys(KEY_ENV) as ProviderId[]).find(hasKey) || DEFAULT_PROVIDER;

const modelId = env.LLM_MODEL?.trim() || DEFAULT_MODELS[provider];
if (!env[KEY_ENV[provider]]) {
return { configured: false, provider, modelId, reason: `LLM_PROVIDER is "${provider}" but ${KEY_ENV[provider]} is not set.` };
if (!hasKey(provider)) {
const reason =
provider === "gateway"
? "No LLM provider is configured. Set AI_GATEWAY_API_KEY in .env.local (or deploy on Vercel), or pick another provider with LLM_PROVIDER."
: `LLM_PROVIDER is "${provider}" but ${KEY_ENV[provider][0]} is not set.`;
return { configured: false, provider, modelId, reason };
}
return { configured: true, provider, modelId };
}

export function getLanguageModel(config: Extract<ModelConfig, { configured: true }>): LanguageModel {
const env = process.env;
switch (config.provider) {
case "gateway":
// Without an API key the gateway falls back to the Vercel OIDC token.
return createGateway({ apiKey: env.AI_GATEWAY_API_KEY || undefined })(config.modelId);
case "openai":
return createOpenAI({ apiKey: env.OPENAI_API_KEY })(config.modelId);
case "anthropic":
Expand Down
55 changes: 55 additions & 0 deletions tests/model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { resolveModelConfig } from "@/lib/agent/model";

describe("resolveModelConfig", () => {
it("defaults to the AI Gateway free model", () => {
expect(resolveModelConfig({ AI_GATEWAY_API_KEY: "key" })).toEqual({
configured: true,
provider: "gateway",
modelId: "nvidia/nemotron-3.5-lightning-free",
});
});

it("uses the Vercel OIDC token when no gateway key is set", () => {
expect(resolveModelConfig({ VERCEL_OIDC_TOKEN: "token" })).toMatchObject({ configured: true, provider: "gateway" });
});

it("reports the gateway as missing when nothing is configured", () => {
const config = resolveModelConfig({});
expect(config).toMatchObject({ configured: false, provider: "gateway" });
if (!config.configured) expect(config.reason).toContain("AI_GATEWAY_API_KEY");
});

it("prefers the gateway over other keys when auto-detecting", () => {
expect(resolveModelConfig({ AI_GATEWAY_API_KEY: "key", OPENAI_API_KEY: "sk" })).toMatchObject({ provider: "gateway" });
});

it("falls back to another provider whose key is set", () => {
expect(resolveModelConfig({ OPENAI_API_KEY: "sk" })).toMatchObject({
configured: true,
provider: "openai",
modelId: "gpt-5-mini",
});
});

it("honours LLM_PROVIDER and LLM_MODEL", () => {
const env = { LLM_PROVIDER: "Anthropic", LLM_MODEL: "claude-opus-5-5", ANTHROPIC_API_KEY: "k", AI_GATEWAY_API_KEY: "g" };
expect(resolveModelConfig(env)).toEqual({ configured: true, provider: "anthropic", modelId: "claude-opus-5-5" });
});

it("overrides the gateway model with LLM_MODEL", () => {
expect(resolveModelConfig({ AI_GATEWAY_API_KEY: "key", LLM_MODEL: "reflection/beam-free" })).toMatchObject({
modelId: "reflection/beam-free",
});
});

it("flags an explicit provider without its key", () => {
const config = resolveModelConfig({ LLM_PROVIDER: "google", AI_GATEWAY_API_KEY: "key" });
expect(config).toMatchObject({ configured: false, provider: "google" });
if (!config.configured) expect(config.reason).toContain("GOOGLE_GENERATIVE_AI_API_KEY");
});

it("rejects an unknown provider", () => {
expect(resolveModelConfig({ LLM_PROVIDER: "nope" })).toMatchObject({ configured: false, provider: null });
});
});
Loading