From 5e2008282286376c9867a6b5639c340ce49c3da5 Mon Sep 17 00:00:00 2001 From: yanalialiuk Date: Fri, 12 Jun 2026 13:41:13 +0300 Subject: [PATCH] feat: add Atomic Chat as first-class LLM provider Wire atomic-chat through agent-core (OpenAI-compatible local API at 127.0.0.1:1337), Next template settings/model picker, and doctor scripts. Keyless by default; optional ATOMIC_CHAT_BASE_URL / ATOMIC_CHAT_API_KEY. Co-authored-by: Cursor --- agent-core/README.md | 3 +- agent-core/openapi.yaml | 2 +- agent-core/src/agent.ts | 4 +++ agent-core/src/resolve-model.test.ts | 26 ++++++++++++++++ agent-core/src/resolve-model.ts | 14 ++++++++- agent-core/src/types.ts | 4 +-- agent-templates/express/agent-core/README.md | 3 +- .../express/agent-core/openapi.yaml | 2 +- .../express/agent-core/src/agent.ts | 4 +++ .../agent-core/src/resolve-model.test.ts | 26 ++++++++++++++++ .../express/agent-core/src/resolve-model.ts | 14 ++++++++- .../express/agent-core/src/types.ts | 4 +-- agent-templates/express/scripts/doctor.ts | 17 +++++++++- agent-templates/library/agent-core/README.md | 3 +- .../library/agent-core/openapi.yaml | 2 +- .../library/agent-core/src/agent.ts | 4 +++ .../agent-core/src/resolve-model.test.ts | 26 ++++++++++++++++ .../library/agent-core/src/resolve-model.ts | 14 ++++++++- .../library/agent-core/src/types.ts | 4 +-- agent-templates/library/scripts/doctor.ts | 17 +++++++++- agent-templates/next/agent-core/README.md | 3 +- agent-templates/next/agent-core/openapi.yaml | 2 +- agent-templates/next/agent-core/src/agent.ts | 4 +++ .../next/agent-core/src/resolve-model.test.ts | 26 ++++++++++++++++ .../next/agent-core/src/resolve-model.ts | 14 ++++++++- agent-templates/next/agent-core/src/types.ts | 4 +-- .../(agent)/_components/model-selector.tsx | 3 ++ .../(agent)/_components/settings-panel.tsx | 1 + agent-templates/next/app/(agent)/_config.ts | 7 +++++ .../next/app/(agent)/_lib/config/keys.ts | 31 +++++++++++++++---- .../next/app/(agent)/_lib/config/models.ts | 4 +++ agent-templates/next/app/(agent)/page.tsx | 1 + 32 files changed, 265 insertions(+), 28 deletions(-) diff --git a/agent-core/README.md b/agent-core/README.md index 7eb164fe..3a64fc63 100644 --- a/agent-core/README.md +++ b/agent-core/README.md @@ -309,8 +309,9 @@ const plan = await agent.plan('compare pricing across 5 CDN providers') | Anthropic Claude | `{ provider: 'anthropic', model: 'claude-sonnet-4-6' }` | | OpenAI | `{ provider: 'openai', model: 'gpt-5.4' }` | | Custom (OpenAI-compat) | `{ provider: 'custom-openai', model: 'your-model', baseURL: '...' }` | +| Atomic Chat (local) | `{ provider: 'atomic-chat', model: 'your-model-id' }` — defaults to `http://127.0.0.1:1337/v1`, keyless | -Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). +Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). Atomic Chat is keyless by default; override with `ATOMIC_CHAT_BASE_URL` / `ATOMIC_CHAT_API_KEY` if needed. ## OpenAPI spec diff --git a/agent-core/openapi.yaml b/agent-core/openapi.yaml index 6b2dda23..c361b163 100644 --- a/agent-core/openapi.yaml +++ b/agent-core/openapi.yaml @@ -211,7 +211,7 @@ components: properties: provider: type: string - enum: [anthropic, openai, google, gateway, custom-openai] + enum: [anthropic, openai, google, gateway, custom-openai, atomic-chat] description: LLM provider. model: type: string diff --git a/agent-core/src/agent.ts b/agent-core/src/agent.ts index 64f5daa2..3187c0e6 100644 --- a/agent-core/src/agent.ts +++ b/agent-core/src/agent.ts @@ -628,6 +628,7 @@ export function createAgentFromEnv(overrides?: Partial): Fir GOOGLE_GENERATIVE_AI_API_KEY: "google", AI_GATEWAY_API_KEY: "gateway", CUSTOM_OPENAI_API_KEY: "custom-openai", + ATOMIC_CHAT_API_KEY: "atomic-chat", }; for (const [env, id] of Object.entries(envMap)) { if (process.env[env]) apiKeys[id] = process.env[env]!; @@ -635,6 +636,9 @@ export function createAgentFromEnv(overrides?: Partial): Fir if (process.env.CUSTOM_OPENAI_BASE_URL) { apiKeys["custom-openai:baseURL"] = process.env.CUSTOM_OPENAI_BASE_URL; } + if (process.env.ATOMIC_CHAT_BASE_URL) { + apiKeys["atomic-chat:baseURL"] = process.env.ATOMIC_CHAT_BASE_URL; + } // Support both MODEL="provider:id" shorthand and the split // MODEL_PROVIDER + MODEL_ID env vars. Overrides always win. diff --git a/agent-core/src/resolve-model.test.ts b/agent-core/src/resolve-model.test.ts index 0e1701de..29e00976 100644 --- a/agent-core/src/resolve-model.test.ts +++ b/agent-core/src/resolve-model.test.ts @@ -96,6 +96,32 @@ describe("resolveModel", () => { ).rejects.toThrow("CUSTOM_OPENAI_BASE_URL is not configured"); }); + it("resolves atomic-chat with default baseURL and keyless token", async () => { + const model = await resolveModel({ + provider: "atomic-chat", + model: "local-model", + }); + expect(model).toMatchObject({ + provider: "openai", + model: "local-model", + apiKey: "atomic-chat-local", + baseURL: "http://127.0.0.1:1337/v1", + }); + }); + + it("resolves atomic-chat with custom baseURL and api key", async () => { + const model = await resolveModel( + { provider: "atomic-chat", model: "qwen-local" }, + { "atomic-chat": "my-key", "atomic-chat:baseURL": "http://host.docker.internal:1337/v1" }, + ); + expect(model).toMatchObject({ + provider: "openai", + model: "qwen-local", + apiKey: "my-key", + baseURL: "http://host.docker.internal:1337/v1", + }); + }); + it("prefers config.apiKey over apiKeys map", async () => { const model = await resolveModel( { provider: "google", model: "gemini-flash", apiKey: "config-key" }, diff --git a/agent-core/src/resolve-model.ts b/agent-core/src/resolve-model.ts index 9aac1bb3..66461eea 100644 --- a/agent-core/src/resolve-model.ts +++ b/agent-core/src/resolve-model.ts @@ -1,6 +1,10 @@ import type { LanguageModel } from "ai"; import type { ModelConfig } from "./types"; +/** Placeholder API key for keyless local Atomic Chat (OpenAI client requires a string). */ +export const ATOMIC_CHAT_DEFAULT_API_KEY = "atomic-chat-local"; +export const ATOMIC_CHAT_DEFAULT_BASE_URL = "http://127.0.0.1:1337/v1"; + /** * Resolve a ModelConfig to an AI SDK LanguageModel instance. * API keys can come from the config itself or the apiKeys map. @@ -41,6 +45,14 @@ export async function resolveModel( baseURL, })(config.model); } + case "atomic-chat": { + const { createOpenAI } = await import("@ai-sdk/openai"); + const baseURL = config.baseURL || apiKeys?.["atomic-chat:baseURL"] || ATOMIC_CHAT_DEFAULT_BASE_URL; + return createOpenAI({ + apiKey: keyFor("atomic-chat") || ATOMIC_CHAT_DEFAULT_API_KEY, + baseURL, + })(config.model); + } case "google": { const { createGoogleGenerativeAI } = await import("@ai-sdk/google"); return createGoogleGenerativeAI({ apiKey: keyFor("google") })(config.model); @@ -52,7 +64,7 @@ export async function resolveModel( const looksLikeModelId = /[-.]/.test(config.provider); const hint = looksLikeModelId ? `. Did you mean MODEL="anthropic:${config.provider}" or similar? Format is "provider:model-id"` - : `. Supported: anthropic, openai, google, gateway, custom-openai`; + : `. Supported: anthropic, openai, google, gateway, custom-openai, atomic-chat`; throw new Error(`Unsupported provider: "${config.provider}"${hint}`); } } diff --git a/agent-core/src/types.ts b/agent-core/src/types.ts index c777f88d..f274e630 100644 --- a/agent-core/src/types.ts +++ b/agent-core/src/types.ts @@ -31,8 +31,8 @@ export interface Toolkit { } export interface ModelConfig { - /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint */ - provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai"; + /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint, "atomic-chat" = local Atomic Chat */ + provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai" | "atomic-chat"; /** Model ID (e.g. "claude-sonnet-4-6", "gemini-3-flash-preview", "gpt-5.4") */ model: string; /** Override the provider API key for this specific model */ diff --git a/agent-templates/express/agent-core/README.md b/agent-templates/express/agent-core/README.md index 7eb164fe..3a64fc63 100644 --- a/agent-templates/express/agent-core/README.md +++ b/agent-templates/express/agent-core/README.md @@ -309,8 +309,9 @@ const plan = await agent.plan('compare pricing across 5 CDN providers') | Anthropic Claude | `{ provider: 'anthropic', model: 'claude-sonnet-4-6' }` | | OpenAI | `{ provider: 'openai', model: 'gpt-5.4' }` | | Custom (OpenAI-compat) | `{ provider: 'custom-openai', model: 'your-model', baseURL: '...' }` | +| Atomic Chat (local) | `{ provider: 'atomic-chat', model: 'your-model-id' }` — defaults to `http://127.0.0.1:1337/v1`, keyless | -Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). +Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). Atomic Chat is keyless by default; override with `ATOMIC_CHAT_BASE_URL` / `ATOMIC_CHAT_API_KEY` if needed. ## OpenAPI spec diff --git a/agent-templates/express/agent-core/openapi.yaml b/agent-templates/express/agent-core/openapi.yaml index 6b2dda23..c361b163 100644 --- a/agent-templates/express/agent-core/openapi.yaml +++ b/agent-templates/express/agent-core/openapi.yaml @@ -211,7 +211,7 @@ components: properties: provider: type: string - enum: [anthropic, openai, google, gateway, custom-openai] + enum: [anthropic, openai, google, gateway, custom-openai, atomic-chat] description: LLM provider. model: type: string diff --git a/agent-templates/express/agent-core/src/agent.ts b/agent-templates/express/agent-core/src/agent.ts index 64f5daa2..3187c0e6 100644 --- a/agent-templates/express/agent-core/src/agent.ts +++ b/agent-templates/express/agent-core/src/agent.ts @@ -628,6 +628,7 @@ export function createAgentFromEnv(overrides?: Partial): Fir GOOGLE_GENERATIVE_AI_API_KEY: "google", AI_GATEWAY_API_KEY: "gateway", CUSTOM_OPENAI_API_KEY: "custom-openai", + ATOMIC_CHAT_API_KEY: "atomic-chat", }; for (const [env, id] of Object.entries(envMap)) { if (process.env[env]) apiKeys[id] = process.env[env]!; @@ -635,6 +636,9 @@ export function createAgentFromEnv(overrides?: Partial): Fir if (process.env.CUSTOM_OPENAI_BASE_URL) { apiKeys["custom-openai:baseURL"] = process.env.CUSTOM_OPENAI_BASE_URL; } + if (process.env.ATOMIC_CHAT_BASE_URL) { + apiKeys["atomic-chat:baseURL"] = process.env.ATOMIC_CHAT_BASE_URL; + } // Support both MODEL="provider:id" shorthand and the split // MODEL_PROVIDER + MODEL_ID env vars. Overrides always win. diff --git a/agent-templates/express/agent-core/src/resolve-model.test.ts b/agent-templates/express/agent-core/src/resolve-model.test.ts index 0e1701de..29e00976 100644 --- a/agent-templates/express/agent-core/src/resolve-model.test.ts +++ b/agent-templates/express/agent-core/src/resolve-model.test.ts @@ -96,6 +96,32 @@ describe("resolveModel", () => { ).rejects.toThrow("CUSTOM_OPENAI_BASE_URL is not configured"); }); + it("resolves atomic-chat with default baseURL and keyless token", async () => { + const model = await resolveModel({ + provider: "atomic-chat", + model: "local-model", + }); + expect(model).toMatchObject({ + provider: "openai", + model: "local-model", + apiKey: "atomic-chat-local", + baseURL: "http://127.0.0.1:1337/v1", + }); + }); + + it("resolves atomic-chat with custom baseURL and api key", async () => { + const model = await resolveModel( + { provider: "atomic-chat", model: "qwen-local" }, + { "atomic-chat": "my-key", "atomic-chat:baseURL": "http://host.docker.internal:1337/v1" }, + ); + expect(model).toMatchObject({ + provider: "openai", + model: "qwen-local", + apiKey: "my-key", + baseURL: "http://host.docker.internal:1337/v1", + }); + }); + it("prefers config.apiKey over apiKeys map", async () => { const model = await resolveModel( { provider: "google", model: "gemini-flash", apiKey: "config-key" }, diff --git a/agent-templates/express/agent-core/src/resolve-model.ts b/agent-templates/express/agent-core/src/resolve-model.ts index 9aac1bb3..66461eea 100644 --- a/agent-templates/express/agent-core/src/resolve-model.ts +++ b/agent-templates/express/agent-core/src/resolve-model.ts @@ -1,6 +1,10 @@ import type { LanguageModel } from "ai"; import type { ModelConfig } from "./types"; +/** Placeholder API key for keyless local Atomic Chat (OpenAI client requires a string). */ +export const ATOMIC_CHAT_DEFAULT_API_KEY = "atomic-chat-local"; +export const ATOMIC_CHAT_DEFAULT_BASE_URL = "http://127.0.0.1:1337/v1"; + /** * Resolve a ModelConfig to an AI SDK LanguageModel instance. * API keys can come from the config itself or the apiKeys map. @@ -41,6 +45,14 @@ export async function resolveModel( baseURL, })(config.model); } + case "atomic-chat": { + const { createOpenAI } = await import("@ai-sdk/openai"); + const baseURL = config.baseURL || apiKeys?.["atomic-chat:baseURL"] || ATOMIC_CHAT_DEFAULT_BASE_URL; + return createOpenAI({ + apiKey: keyFor("atomic-chat") || ATOMIC_CHAT_DEFAULT_API_KEY, + baseURL, + })(config.model); + } case "google": { const { createGoogleGenerativeAI } = await import("@ai-sdk/google"); return createGoogleGenerativeAI({ apiKey: keyFor("google") })(config.model); @@ -52,7 +64,7 @@ export async function resolveModel( const looksLikeModelId = /[-.]/.test(config.provider); const hint = looksLikeModelId ? `. Did you mean MODEL="anthropic:${config.provider}" or similar? Format is "provider:model-id"` - : `. Supported: anthropic, openai, google, gateway, custom-openai`; + : `. Supported: anthropic, openai, google, gateway, custom-openai, atomic-chat`; throw new Error(`Unsupported provider: "${config.provider}"${hint}`); } } diff --git a/agent-templates/express/agent-core/src/types.ts b/agent-templates/express/agent-core/src/types.ts index c777f88d..f274e630 100644 --- a/agent-templates/express/agent-core/src/types.ts +++ b/agent-templates/express/agent-core/src/types.ts @@ -31,8 +31,8 @@ export interface Toolkit { } export interface ModelConfig { - /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint */ - provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai"; + /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint, "atomic-chat" = local Atomic Chat */ + provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai" | "atomic-chat"; /** Model ID (e.g. "claude-sonnet-4-6", "gemini-3-flash-preview", "gpt-5.4") */ model: string; /** Override the provider API key for this specific model */ diff --git a/agent-templates/express/scripts/doctor.ts b/agent-templates/express/scripts/doctor.ts index 558f2e9e..11aac924 100644 --- a/agent-templates/express/scripts/doctor.ts +++ b/agent-templates/express/scripts/doctor.ts @@ -49,10 +49,25 @@ const providerKeyEnv: Record = { google: "GOOGLE_GENERATIVE_AI_API_KEY", gateway: "AI_GATEWAY_API_KEY", "custom-openai": "CUSTOM_OPENAI_API_KEY", + "atomic-chat": "ATOMIC_CHAT_API_KEY", }; const requiredKey = providerKeyEnv[provider]; -if (requiredKey) { +if (requiredKey && provider !== "atomic-chat") { checkKey(requiredKey, `${requiredKey} (for provider "${provider}")`, true); +} else if (provider === "atomic-chat") { + const key = process.env.ATOMIC_CHAT_API_KEY; + if (key) checkKey("ATOMIC_CHAT_API_KEY", "ATOMIC_CHAT_API_KEY (optional)", false); +} + +// --- atomic-chat baseURL (optional override) --- +if (provider === "atomic-chat") { + const baseURL = process.env.ATOMIC_CHAT_BASE_URL ?? "http://127.0.0.1:1337/v1"; + try { + new URL(baseURL); + add("ATOMIC_CHAT_BASE_URL", "ok", baseURL); + } catch { + add("ATOMIC_CHAT_BASE_URL", "fail", `invalid URL: "${baseURL}"`); + } } // --- custom-openai baseURL --- diff --git a/agent-templates/library/agent-core/README.md b/agent-templates/library/agent-core/README.md index 7eb164fe..3a64fc63 100644 --- a/agent-templates/library/agent-core/README.md +++ b/agent-templates/library/agent-core/README.md @@ -309,8 +309,9 @@ const plan = await agent.plan('compare pricing across 5 CDN providers') | Anthropic Claude | `{ provider: 'anthropic', model: 'claude-sonnet-4-6' }` | | OpenAI | `{ provider: 'openai', model: 'gpt-5.4' }` | | Custom (OpenAI-compat) | `{ provider: 'custom-openai', model: 'your-model', baseURL: '...' }` | +| Atomic Chat (local) | `{ provider: 'atomic-chat', model: 'your-model-id' }` — defaults to `http://127.0.0.1:1337/v1`, keyless | -Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). +Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). Atomic Chat is keyless by default; override with `ATOMIC_CHAT_BASE_URL` / `ATOMIC_CHAT_API_KEY` if needed. ## OpenAPI spec diff --git a/agent-templates/library/agent-core/openapi.yaml b/agent-templates/library/agent-core/openapi.yaml index 6b2dda23..c361b163 100644 --- a/agent-templates/library/agent-core/openapi.yaml +++ b/agent-templates/library/agent-core/openapi.yaml @@ -211,7 +211,7 @@ components: properties: provider: type: string - enum: [anthropic, openai, google, gateway, custom-openai] + enum: [anthropic, openai, google, gateway, custom-openai, atomic-chat] description: LLM provider. model: type: string diff --git a/agent-templates/library/agent-core/src/agent.ts b/agent-templates/library/agent-core/src/agent.ts index 64f5daa2..3187c0e6 100644 --- a/agent-templates/library/agent-core/src/agent.ts +++ b/agent-templates/library/agent-core/src/agent.ts @@ -628,6 +628,7 @@ export function createAgentFromEnv(overrides?: Partial): Fir GOOGLE_GENERATIVE_AI_API_KEY: "google", AI_GATEWAY_API_KEY: "gateway", CUSTOM_OPENAI_API_KEY: "custom-openai", + ATOMIC_CHAT_API_KEY: "atomic-chat", }; for (const [env, id] of Object.entries(envMap)) { if (process.env[env]) apiKeys[id] = process.env[env]!; @@ -635,6 +636,9 @@ export function createAgentFromEnv(overrides?: Partial): Fir if (process.env.CUSTOM_OPENAI_BASE_URL) { apiKeys["custom-openai:baseURL"] = process.env.CUSTOM_OPENAI_BASE_URL; } + if (process.env.ATOMIC_CHAT_BASE_URL) { + apiKeys["atomic-chat:baseURL"] = process.env.ATOMIC_CHAT_BASE_URL; + } // Support both MODEL="provider:id" shorthand and the split // MODEL_PROVIDER + MODEL_ID env vars. Overrides always win. diff --git a/agent-templates/library/agent-core/src/resolve-model.test.ts b/agent-templates/library/agent-core/src/resolve-model.test.ts index 0e1701de..29e00976 100644 --- a/agent-templates/library/agent-core/src/resolve-model.test.ts +++ b/agent-templates/library/agent-core/src/resolve-model.test.ts @@ -96,6 +96,32 @@ describe("resolveModel", () => { ).rejects.toThrow("CUSTOM_OPENAI_BASE_URL is not configured"); }); + it("resolves atomic-chat with default baseURL and keyless token", async () => { + const model = await resolveModel({ + provider: "atomic-chat", + model: "local-model", + }); + expect(model).toMatchObject({ + provider: "openai", + model: "local-model", + apiKey: "atomic-chat-local", + baseURL: "http://127.0.0.1:1337/v1", + }); + }); + + it("resolves atomic-chat with custom baseURL and api key", async () => { + const model = await resolveModel( + { provider: "atomic-chat", model: "qwen-local" }, + { "atomic-chat": "my-key", "atomic-chat:baseURL": "http://host.docker.internal:1337/v1" }, + ); + expect(model).toMatchObject({ + provider: "openai", + model: "qwen-local", + apiKey: "my-key", + baseURL: "http://host.docker.internal:1337/v1", + }); + }); + it("prefers config.apiKey over apiKeys map", async () => { const model = await resolveModel( { provider: "google", model: "gemini-flash", apiKey: "config-key" }, diff --git a/agent-templates/library/agent-core/src/resolve-model.ts b/agent-templates/library/agent-core/src/resolve-model.ts index 9aac1bb3..66461eea 100644 --- a/agent-templates/library/agent-core/src/resolve-model.ts +++ b/agent-templates/library/agent-core/src/resolve-model.ts @@ -1,6 +1,10 @@ import type { LanguageModel } from "ai"; import type { ModelConfig } from "./types"; +/** Placeholder API key for keyless local Atomic Chat (OpenAI client requires a string). */ +export const ATOMIC_CHAT_DEFAULT_API_KEY = "atomic-chat-local"; +export const ATOMIC_CHAT_DEFAULT_BASE_URL = "http://127.0.0.1:1337/v1"; + /** * Resolve a ModelConfig to an AI SDK LanguageModel instance. * API keys can come from the config itself or the apiKeys map. @@ -41,6 +45,14 @@ export async function resolveModel( baseURL, })(config.model); } + case "atomic-chat": { + const { createOpenAI } = await import("@ai-sdk/openai"); + const baseURL = config.baseURL || apiKeys?.["atomic-chat:baseURL"] || ATOMIC_CHAT_DEFAULT_BASE_URL; + return createOpenAI({ + apiKey: keyFor("atomic-chat") || ATOMIC_CHAT_DEFAULT_API_KEY, + baseURL, + })(config.model); + } case "google": { const { createGoogleGenerativeAI } = await import("@ai-sdk/google"); return createGoogleGenerativeAI({ apiKey: keyFor("google") })(config.model); @@ -52,7 +64,7 @@ export async function resolveModel( const looksLikeModelId = /[-.]/.test(config.provider); const hint = looksLikeModelId ? `. Did you mean MODEL="anthropic:${config.provider}" or similar? Format is "provider:model-id"` - : `. Supported: anthropic, openai, google, gateway, custom-openai`; + : `. Supported: anthropic, openai, google, gateway, custom-openai, atomic-chat`; throw new Error(`Unsupported provider: "${config.provider}"${hint}`); } } diff --git a/agent-templates/library/agent-core/src/types.ts b/agent-templates/library/agent-core/src/types.ts index c777f88d..f274e630 100644 --- a/agent-templates/library/agent-core/src/types.ts +++ b/agent-templates/library/agent-core/src/types.ts @@ -31,8 +31,8 @@ export interface Toolkit { } export interface ModelConfig { - /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint */ - provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai"; + /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint, "atomic-chat" = local Atomic Chat */ + provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai" | "atomic-chat"; /** Model ID (e.g. "claude-sonnet-4-6", "gemini-3-flash-preview", "gpt-5.4") */ model: string; /** Override the provider API key for this specific model */ diff --git a/agent-templates/library/scripts/doctor.ts b/agent-templates/library/scripts/doctor.ts index 558f2e9e..11aac924 100644 --- a/agent-templates/library/scripts/doctor.ts +++ b/agent-templates/library/scripts/doctor.ts @@ -49,10 +49,25 @@ const providerKeyEnv: Record = { google: "GOOGLE_GENERATIVE_AI_API_KEY", gateway: "AI_GATEWAY_API_KEY", "custom-openai": "CUSTOM_OPENAI_API_KEY", + "atomic-chat": "ATOMIC_CHAT_API_KEY", }; const requiredKey = providerKeyEnv[provider]; -if (requiredKey) { +if (requiredKey && provider !== "atomic-chat") { checkKey(requiredKey, `${requiredKey} (for provider "${provider}")`, true); +} else if (provider === "atomic-chat") { + const key = process.env.ATOMIC_CHAT_API_KEY; + if (key) checkKey("ATOMIC_CHAT_API_KEY", "ATOMIC_CHAT_API_KEY (optional)", false); +} + +// --- atomic-chat baseURL (optional override) --- +if (provider === "atomic-chat") { + const baseURL = process.env.ATOMIC_CHAT_BASE_URL ?? "http://127.0.0.1:1337/v1"; + try { + new URL(baseURL); + add("ATOMIC_CHAT_BASE_URL", "ok", baseURL); + } catch { + add("ATOMIC_CHAT_BASE_URL", "fail", `invalid URL: "${baseURL}"`); + } } // --- custom-openai baseURL --- diff --git a/agent-templates/next/agent-core/README.md b/agent-templates/next/agent-core/README.md index 7eb164fe..3a64fc63 100644 --- a/agent-templates/next/agent-core/README.md +++ b/agent-templates/next/agent-core/README.md @@ -309,8 +309,9 @@ const plan = await agent.plan('compare pricing across 5 CDN providers') | Anthropic Claude | `{ provider: 'anthropic', model: 'claude-sonnet-4-6' }` | | OpenAI | `{ provider: 'openai', model: 'gpt-5.4' }` | | Custom (OpenAI-compat) | `{ provider: 'custom-openai', model: 'your-model', baseURL: '...' }` | +| Atomic Chat (local) | `{ provider: 'atomic-chat', model: 'your-model-id' }` — defaults to `http://127.0.0.1:1337/v1`, keyless | -Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). +Set API keys via `apiKeys` option or environment variables (`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). Atomic Chat is keyless by default; override with `ATOMIC_CHAT_BASE_URL` / `ATOMIC_CHAT_API_KEY` if needed. ## OpenAPI spec diff --git a/agent-templates/next/agent-core/openapi.yaml b/agent-templates/next/agent-core/openapi.yaml index 6b2dda23..c361b163 100644 --- a/agent-templates/next/agent-core/openapi.yaml +++ b/agent-templates/next/agent-core/openapi.yaml @@ -211,7 +211,7 @@ components: properties: provider: type: string - enum: [anthropic, openai, google, gateway, custom-openai] + enum: [anthropic, openai, google, gateway, custom-openai, atomic-chat] description: LLM provider. model: type: string diff --git a/agent-templates/next/agent-core/src/agent.ts b/agent-templates/next/agent-core/src/agent.ts index 64f5daa2..3187c0e6 100644 --- a/agent-templates/next/agent-core/src/agent.ts +++ b/agent-templates/next/agent-core/src/agent.ts @@ -628,6 +628,7 @@ export function createAgentFromEnv(overrides?: Partial): Fir GOOGLE_GENERATIVE_AI_API_KEY: "google", AI_GATEWAY_API_KEY: "gateway", CUSTOM_OPENAI_API_KEY: "custom-openai", + ATOMIC_CHAT_API_KEY: "atomic-chat", }; for (const [env, id] of Object.entries(envMap)) { if (process.env[env]) apiKeys[id] = process.env[env]!; @@ -635,6 +636,9 @@ export function createAgentFromEnv(overrides?: Partial): Fir if (process.env.CUSTOM_OPENAI_BASE_URL) { apiKeys["custom-openai:baseURL"] = process.env.CUSTOM_OPENAI_BASE_URL; } + if (process.env.ATOMIC_CHAT_BASE_URL) { + apiKeys["atomic-chat:baseURL"] = process.env.ATOMIC_CHAT_BASE_URL; + } // Support both MODEL="provider:id" shorthand and the split // MODEL_PROVIDER + MODEL_ID env vars. Overrides always win. diff --git a/agent-templates/next/agent-core/src/resolve-model.test.ts b/agent-templates/next/agent-core/src/resolve-model.test.ts index 0e1701de..29e00976 100644 --- a/agent-templates/next/agent-core/src/resolve-model.test.ts +++ b/agent-templates/next/agent-core/src/resolve-model.test.ts @@ -96,6 +96,32 @@ describe("resolveModel", () => { ).rejects.toThrow("CUSTOM_OPENAI_BASE_URL is not configured"); }); + it("resolves atomic-chat with default baseURL and keyless token", async () => { + const model = await resolveModel({ + provider: "atomic-chat", + model: "local-model", + }); + expect(model).toMatchObject({ + provider: "openai", + model: "local-model", + apiKey: "atomic-chat-local", + baseURL: "http://127.0.0.1:1337/v1", + }); + }); + + it("resolves atomic-chat with custom baseURL and api key", async () => { + const model = await resolveModel( + { provider: "atomic-chat", model: "qwen-local" }, + { "atomic-chat": "my-key", "atomic-chat:baseURL": "http://host.docker.internal:1337/v1" }, + ); + expect(model).toMatchObject({ + provider: "openai", + model: "qwen-local", + apiKey: "my-key", + baseURL: "http://host.docker.internal:1337/v1", + }); + }); + it("prefers config.apiKey over apiKeys map", async () => { const model = await resolveModel( { provider: "google", model: "gemini-flash", apiKey: "config-key" }, diff --git a/agent-templates/next/agent-core/src/resolve-model.ts b/agent-templates/next/agent-core/src/resolve-model.ts index 9aac1bb3..66461eea 100644 --- a/agent-templates/next/agent-core/src/resolve-model.ts +++ b/agent-templates/next/agent-core/src/resolve-model.ts @@ -1,6 +1,10 @@ import type { LanguageModel } from "ai"; import type { ModelConfig } from "./types"; +/** Placeholder API key for keyless local Atomic Chat (OpenAI client requires a string). */ +export const ATOMIC_CHAT_DEFAULT_API_KEY = "atomic-chat-local"; +export const ATOMIC_CHAT_DEFAULT_BASE_URL = "http://127.0.0.1:1337/v1"; + /** * Resolve a ModelConfig to an AI SDK LanguageModel instance. * API keys can come from the config itself or the apiKeys map. @@ -41,6 +45,14 @@ export async function resolveModel( baseURL, })(config.model); } + case "atomic-chat": { + const { createOpenAI } = await import("@ai-sdk/openai"); + const baseURL = config.baseURL || apiKeys?.["atomic-chat:baseURL"] || ATOMIC_CHAT_DEFAULT_BASE_URL; + return createOpenAI({ + apiKey: keyFor("atomic-chat") || ATOMIC_CHAT_DEFAULT_API_KEY, + baseURL, + })(config.model); + } case "google": { const { createGoogleGenerativeAI } = await import("@ai-sdk/google"); return createGoogleGenerativeAI({ apiKey: keyFor("google") })(config.model); @@ -52,7 +64,7 @@ export async function resolveModel( const looksLikeModelId = /[-.]/.test(config.provider); const hint = looksLikeModelId ? `. Did you mean MODEL="anthropic:${config.provider}" or similar? Format is "provider:model-id"` - : `. Supported: anthropic, openai, google, gateway, custom-openai`; + : `. Supported: anthropic, openai, google, gateway, custom-openai, atomic-chat`; throw new Error(`Unsupported provider: "${config.provider}"${hint}`); } } diff --git a/agent-templates/next/agent-core/src/types.ts b/agent-templates/next/agent-core/src/types.ts index c777f88d..f274e630 100644 --- a/agent-templates/next/agent-core/src/types.ts +++ b/agent-templates/next/agent-core/src/types.ts @@ -31,8 +31,8 @@ export interface Toolkit { } export interface ModelConfig { - /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint */ - provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai"; + /** LLM provider. "gateway" = Vercel AI Gateway, "custom-openai" = any OpenAI-compatible endpoint, "atomic-chat" = local Atomic Chat */ + provider: "gateway" | "anthropic" | "openai" | "google" | "custom-openai" | "atomic-chat"; /** Model ID (e.g. "claude-sonnet-4-6", "gemini-3-flash-preview", "gpt-5.4") */ model: string; /** Override the provider API key for this specific model */ diff --git a/agent-templates/next/app/(agent)/_components/model-selector.tsx b/agent-templates/next/app/(agent)/_components/model-selector.tsx index 13ec3553..03a99e0c 100644 --- a/agent-templates/next/app/(agent)/_components/model-selector.tsx +++ b/agent-templates/next/app/(agent)/_components/model-selector.tsx @@ -14,6 +14,7 @@ const PROVIDERS: { id: Provider; name: string }[] = [ { id: "openai", name: "OpenAI" }, ...(EXPERIMENTAL.customOpenAI ? [{ id: "custom-openai" as Provider, name: "Custom OpenAI" }] : []), { id: "google", name: "Google" }, + { id: "atomic-chat", name: "Atomic Chat" }, ]; // Provider → (label, docs URL). Used to point users at the right console when @@ -24,6 +25,7 @@ const PROVIDER_DOCS: Record = { google: { label: "Google AI", url: "https://aistudio.google.com/apikey" }, gateway: { label: "AI Gateway", url: "https://vercel.com/dashboard/ai-gateway" }, "custom-openai": { label: "Custom OpenAI", url: "" }, + "atomic-chat": { label: "Atomic Chat", url: "https://atomic.chat" }, firecrawl: { label: "Firecrawl", url: "https://firecrawl.dev/app/api-keys" }, }; @@ -35,6 +37,7 @@ const PROVIDER_TO_KEY_ID: Record = { google: "google", gateway: "gateway", "custom-openai": "customOpenAI", + "atomic-chat": "atomicChat", firecrawl: "firecrawl", }; diff --git a/agent-templates/next/app/(agent)/_components/settings-panel.tsx b/agent-templates/next/app/(agent)/_components/settings-panel.tsx index c956a7df..c753933c 100644 --- a/agent-templates/next/app/(agent)/_components/settings-panel.tsx +++ b/agent-templates/next/app/(agent)/_components/settings-panel.tsx @@ -30,6 +30,7 @@ const PROVIDERS = [ { id: "google", label: "Google AI", icon: "gemini", envVar: "GOOGLE_GENERATIVE_AI_API_KEY", placeholder: "AI...", description: "Gemini models.", required: false, hasModels: true, provider: "google" as Provider }, { id: "gateway", label: "AI Gateway", icon: "vercel", envVar: "AI_GATEWAY_API_KEY", placeholder: "vck_...", description: "Vercel AI Gateway. Access multiple providers through a single key.", required: false, hasModels: true, provider: "gateway" as Provider }, ...(EXPERIMENTAL.customOpenAI ? [{ id: "customOpenAI", label: "Custom OpenAI", icon: "openai", envVar: "CUSTOM_OPENAI_API_KEY", placeholder: "sk-...", description: "Any OpenAI-compatible provider using your own base URL and model IDs.", required: false, hasModels: true, provider: "custom-openai" as Provider, extraField: { id: "customOpenAIBaseURL", label: "Base URL", placeholder: "https://openrouter.ai/api/v1", help: "Saved separately and used as the default base URL for the custom-openai provider." } }] : []), + { id: "atomicChat", label: "Atomic Chat", icon: "openai", envVar: "ATOMIC_CHAT_API_KEY", placeholder: "optional (keyless local)", description: "Local OpenAI-compatible API from Atomic Chat. Defaults to http://127.0.0.1:1337/v1 — no API key required.", required: false, hasModels: true, provider: "atomic-chat" as Provider, extraField: { id: "atomicChatBaseURL", label: "Base URL", placeholder: "http://127.0.0.1:1337/v1", help: "Override the Atomic Chat endpoint (e.g. http://host.docker.internal:1337/v1 in Docker)." } }, ]; function StatusDot({ configured, required }: { configured: boolean; required?: boolean }) { diff --git a/agent-templates/next/app/(agent)/_config.ts b/agent-templates/next/app/(agent)/_config.ts index 94984d4a..3c605b07 100644 --- a/agent-templates/next/app/(agent)/_config.ts +++ b/agent-templates/next/app/(agent)/_config.ts @@ -37,6 +37,13 @@ export const config = { // subAgent: { provider: "custom-openai", model: "gpt-4.1", baseURL: "https://openrouter.ai/api/v1" } satisfies ModelRef, // background: { provider: "custom-openai", model: "gpt-4.1", baseURL: "https://openrouter.ai/api/v1" } satisfies ModelRef, + // ═══════════════════════════════════════════ + // Atomic Chat (local OpenAI-compatible) + // ═══════════════════════════════════════════ + // orchestrator: { provider: "atomic-chat", model: "local" } satisfies ModelRef, + // subAgent: { provider: "atomic-chat", model: "local" } satisfies ModelRef, + // background: { provider: "atomic-chat", model: "local" } satisfies ModelRef, + // ─── Parallel workers ─── maxWorkers: 10, // Max concurrent worker agents workerMaxSteps: 50, // Max steps per worker — let them finish complex tasks diff --git a/agent-templates/next/app/(agent)/_lib/config/keys.ts b/agent-templates/next/app/(agent)/_lib/config/keys.ts index b29a1623..2c768267 100644 --- a/agent-templates/next/app/(agent)/_lib/config/keys.ts +++ b/agent-templates/next/app/(agent)/_lib/config/keys.ts @@ -11,6 +11,7 @@ export const KEY_DEFS = { google: { env: "GOOGLE_GENERATIVE_AI_API_KEY", label: "Google AI", placeholder: "AI..." }, gateway: { env: "AI_GATEWAY_API_KEY", label: "AI Gateway", placeholder: "vck_..." }, customOpenAI: { env: "CUSTOM_OPENAI_API_KEY", label: "Custom OpenAI", placeholder: "sk-..." }, + atomicChat: { env: "ATOMIC_CHAT_API_KEY", label: "Atomic Chat", placeholder: "atomic-chat-local (optional)" }, } as const; export type KeyId = keyof typeof KEY_DEFS; @@ -21,6 +22,11 @@ export const VALUE_DEFS = { label: "Base URL", placeholder: "https://openrouter.ai/api/v1", }, + atomicChatBaseURL: { + env: "ATOMIC_CHAT_BASE_URL", + label: "Base URL", + placeholder: "http://127.0.0.1:1337/v1", + }, } as const; export type ValueId = keyof typeof VALUE_DEFS; @@ -70,6 +76,7 @@ export function getProviderKey(provider: string): string | undefined { google: "google", gateway: "gateway", "custom-openai": "customOpenAI", + "atomic-chat": "atomicChat", }; const id = map[provider]; return id ? getKey(id) : undefined; @@ -84,6 +91,10 @@ export function getCustomOpenAIBaseURL(): string | undefined { return getValue("customOpenAIBaseURL"); } +export function getAtomicChatBaseURL(): string | undefined { + return getValue("atomicChatBaseURL"); +} + export function maskKey(key: string): string { if (!key || key.length < 8) return key ? "****" : ""; return key.slice(0, 6) + "..." + key.slice(-4); @@ -179,17 +190,25 @@ export function setValue(id: ValueId, value: string) { export function getProviderApiKeys(): Record { const keys: Record = {}; - for (const provider of ["anthropic", "openai", "google", "gateway", "custom-openai"] as const) { + for (const provider of ["anthropic", "openai", "google", "gateway", "custom-openai", "atomic-chat"] as const) { const key = getProviderKey(provider); if (key) keys[provider] = key; } - const baseURL = getCustomOpenAIBaseURL(); - if (baseURL) keys["custom-openai:baseURL"] = baseURL; + const customBaseURL = getCustomOpenAIBaseURL(); + if (customBaseURL) keys["custom-openai:baseURL"] = customBaseURL; + const atomicBaseURL = getAtomicChatBaseURL(); + if (atomicBaseURL) keys["atomic-chat:baseURL"] = atomicBaseURL; return keys; } export function hydrateModelConfig(model: ModelConfig): ModelConfig { - if (model.provider !== "custom-openai" || model.baseURL) return model; - const baseURL = getCustomOpenAIBaseURL(); - return baseURL ? { ...model, baseURL } : model; + if (model.provider === "custom-openai" && !model.baseURL) { + const baseURL = getCustomOpenAIBaseURL(); + return baseURL ? { ...model, baseURL } : model; + } + if (model.provider === "atomic-chat" && !model.baseURL) { + const baseURL = getAtomicChatBaseURL(); + return baseURL ? { ...model, baseURL } : model; + } + return model; } diff --git a/agent-templates/next/app/(agent)/_lib/config/models.ts b/agent-templates/next/app/(agent)/_lib/config/models.ts index fc3b58f4..8e9d2491 100644 --- a/agent-templates/next/app/(agent)/_lib/config/models.ts +++ b/agent-templates/next/app/(agent)/_lib/config/models.ts @@ -26,6 +26,9 @@ export const AVAILABLE_MODELS = { { id: "gemini-2.5-pro-preview-05-06", name: "Gemini 2.5 Pro", icon: "gemini" }, { id: "gemini-2.5-flash-preview-05-20", name: "Gemini 2.5 Flash", icon: "gemini" }, ], + "atomic-chat": [ + { id: "local", name: "Local model (see GET /v1/models)", icon: "openai" }, + ], // Spark 1 models are experimental and not yet available for BYOK. // firecrawl: [ // { id: "spark-1-mini", name: "Spark 1 Mini", icon: "firecrawl" }, @@ -40,5 +43,6 @@ export const PROVIDER_META: Record = { anthropic: { name: "Anthropic", icon: "anthropic" }, openai: { name: "OpenAI", icon: "openai" }, "custom-openai": { name: "Custom OpenAI", icon: "openai" }, + "atomic-chat": { name: "Atomic Chat", icon: "openai" }, google: { name: "Google", icon: "google" }, }; diff --git a/agent-templates/next/app/(agent)/page.tsx b/agent-templates/next/app/(agent)/page.tsx index dc860865..abd6701b 100644 --- a/agent-templates/next/app/(agent)/page.tsx +++ b/agent-templates/next/app/(agent)/page.tsx @@ -63,6 +63,7 @@ const PROVIDER_KEY_IDS: Partial> = { google: "google", gateway: "gateway", "custom-openai": "customOpenAI", + "atomic-chat": "atomicChat", }; function sanitizeModelPreference(model: ModelConfig): CachedModelPreference {