diff --git a/packages/agent-providers/cerebras/README.md b/packages/agent-providers/cerebras/README.md new file mode 100644 index 00000000..72dd5bae --- /dev/null +++ b/packages/agent-providers/cerebras/README.md @@ -0,0 +1,12 @@ +# Cerebras Agent Provider + +OpenAI-compatible sh1pt agent provider for Cerebras Inference. + +Required env: + +- `CEREBRAS_API_KEY` + +Optional env: + +- `CEREBRAS_BASE_URL` defaults to `https://api.cerebras.ai/v1` +- `CEREBRAS_MODEL` defaults to `zai-glm-4.7` diff --git a/packages/agent-providers/cerebras/package.json b/packages/agent-providers/cerebras/package.json new file mode 100644 index 00000000..18eadd79 --- /dev/null +++ b/packages/agent-providers/cerebras/package.json @@ -0,0 +1,25 @@ +{ + "name": "@profullstack/sh1pt-agent-provider-cerebras", + "version": "0.1.15", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@profullstack/sh1pt-agent-provider-shared": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/agent-providers/cerebras" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + } +} diff --git a/packages/agent-providers/cerebras/src/index.ts b/packages/agent-providers/cerebras/src/index.ts new file mode 100644 index 00000000..1a8f850f --- /dev/null +++ b/packages/agent-providers/cerebras/src/index.ts @@ -0,0 +1 @@ +export * from "./provider"; diff --git a/packages/agent-providers/cerebras/src/provider.test.ts b/packages/agent-providers/cerebras/src/provider.test.ts new file mode 100644 index 00000000..2597cf28 --- /dev/null +++ b/packages/agent-providers/cerebras/src/provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { cerebrasProvider } from "./provider.js"; + +describe("cerebras agent provider", () => { + it("declares metadata and env", () => { + expect(cerebrasProvider.id).toBe("cerebras"); + expect(cerebrasProvider.displayName).toBe("Cerebras"); + expect(cerebrasProvider.capabilities.chat).toBe(true); + expect(cerebrasProvider.getRequiredEnv()).toEqual( + expect.arrayContaining([ + { key: "CEREBRAS_API_KEY", required: true }, + { key: "CEREBRAS_BASE_URL", required: false }, + { key: "CEREBRAS_MODEL", required: false }, + ]), + ); + }); + + it("requires CEREBRAS_API_KEY", () => { + expect(() => cerebrasProvider.validateEnv({})).toThrow("Missing CEREBRAS_API_KEY"); + expect(() => cerebrasProvider.validateEnv({ CEREBRAS_API_KEY: "sk-test" })).not.toThrow(); + }); +}); diff --git a/packages/agent-providers/cerebras/src/provider.ts b/packages/agent-providers/cerebras/src/provider.ts new file mode 100644 index 00000000..016edcfb --- /dev/null +++ b/packages/agent-providers/cerebras/src/provider.ts @@ -0,0 +1,11 @@ +import { createOpenAICompatibleProvider } from "@profullstack/sh1pt-agent-provider-shared"; + +export const cerebrasProvider = createOpenAICompatibleProvider({ + id: "cerebras", + displayName: "Cerebras", + apiKeyEnv: "CEREBRAS_API_KEY", + baseUrlEnv: "CEREBRAS_BASE_URL", + modelEnv: "CEREBRAS_MODEL", + defaultBaseURL: "https://api.cerebras.ai/v1", + defaultModel: "gpt-oss-120b", +}); diff --git a/packages/agent-providers/cerebras/tsconfig.json b/packages/agent-providers/cerebras/tsconfig.json new file mode 100644 index 00000000..41d4c37c --- /dev/null +++ b/packages/agent-providers/cerebras/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/agent-providers/deepseek/README.md b/packages/agent-providers/deepseek/README.md new file mode 100644 index 00000000..78637fea --- /dev/null +++ b/packages/agent-providers/deepseek/README.md @@ -0,0 +1,12 @@ +# DeepSeek Agent Provider + +OpenAI-compatible sh1pt agent provider for DeepSeek. + +Required env: + +- `DEEPSEEK_API_KEY` + +Optional env: + +- `DEEPSEEK_BASE_URL` defaults to `https://api.deepseek.com` +- `DEEPSEEK_MODEL` defaults to `deepseek-v4-flash` diff --git a/packages/agent-providers/deepseek/package.json b/packages/agent-providers/deepseek/package.json new file mode 100644 index 00000000..a0d76e3a --- /dev/null +++ b/packages/agent-providers/deepseek/package.json @@ -0,0 +1,25 @@ +{ + "name": "@profullstack/sh1pt-agent-provider-deepseek", + "version": "0.1.15", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@profullstack/sh1pt-agent-provider-shared": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/agent-providers/deepseek" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + } +} diff --git a/packages/agent-providers/deepseek/src/index.ts b/packages/agent-providers/deepseek/src/index.ts new file mode 100644 index 00000000..1a8f850f --- /dev/null +++ b/packages/agent-providers/deepseek/src/index.ts @@ -0,0 +1 @@ +export * from "./provider"; diff --git a/packages/agent-providers/deepseek/src/provider.test.ts b/packages/agent-providers/deepseek/src/provider.test.ts new file mode 100644 index 00000000..bace9311 --- /dev/null +++ b/packages/agent-providers/deepseek/src/provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { deepseekProvider } from "./provider.js"; + +describe("deepseek agent provider", () => { + it("declares metadata and env", () => { + expect(deepseekProvider.id).toBe("deepseek"); + expect(deepseekProvider.displayName).toBe("DeepSeek"); + expect(deepseekProvider.capabilities.chat).toBe(true); + expect(deepseekProvider.getRequiredEnv()).toEqual( + expect.arrayContaining([ + { key: "DEEPSEEK_API_KEY", required: true }, + { key: "DEEPSEEK_BASE_URL", required: false }, + { key: "DEEPSEEK_MODEL", required: false }, + ]), + ); + }); + + it("requires DEEPSEEK_API_KEY", () => { + expect(() => deepseekProvider.validateEnv({})).toThrow("Missing DEEPSEEK_API_KEY"); + expect(() => deepseekProvider.validateEnv({ DEEPSEEK_API_KEY: "sk-test" })).not.toThrow(); + }); +}); diff --git a/packages/agent-providers/deepseek/src/provider.ts b/packages/agent-providers/deepseek/src/provider.ts new file mode 100644 index 00000000..b5ed1e14 --- /dev/null +++ b/packages/agent-providers/deepseek/src/provider.ts @@ -0,0 +1,11 @@ +import { createOpenAICompatibleProvider } from "@profullstack/sh1pt-agent-provider-shared"; + +export const deepseekProvider = createOpenAICompatibleProvider({ + id: "deepseek", + displayName: "DeepSeek", + apiKeyEnv: "DEEPSEEK_API_KEY", + baseUrlEnv: "DEEPSEEK_BASE_URL", + modelEnv: "DEEPSEEK_MODEL", + defaultBaseURL: "https://api.deepseek.com", + defaultModel: "deepseek-v4-flash", +}); diff --git a/packages/agent-providers/deepseek/tsconfig.json b/packages/agent-providers/deepseek/tsconfig.json new file mode 100644 index 00000000..41d4c37c --- /dev/null +++ b/packages/agent-providers/deepseek/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/agent-providers/groq/README.md b/packages/agent-providers/groq/README.md new file mode 100644 index 00000000..bb0c795e --- /dev/null +++ b/packages/agent-providers/groq/README.md @@ -0,0 +1,12 @@ +# Groq Agent Provider + +OpenAI-compatible sh1pt agent provider for Groq. + +Required env: + +- `GROQ_API_KEY` + +Optional env: + +- `GROQ_BASE_URL` defaults to `https://api.groq.com/openai/v1` +- `GROQ_MODEL` defaults to `llama-3.3-70b-versatile` diff --git a/packages/agent-providers/groq/package.json b/packages/agent-providers/groq/package.json new file mode 100644 index 00000000..a84ebde5 --- /dev/null +++ b/packages/agent-providers/groq/package.json @@ -0,0 +1,25 @@ +{ + "name": "@profullstack/sh1pt-agent-provider-groq", + "version": "0.1.15", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@profullstack/sh1pt-agent-provider-shared": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/agent-providers/groq" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + } +} diff --git a/packages/agent-providers/groq/src/index.ts b/packages/agent-providers/groq/src/index.ts new file mode 100644 index 00000000..1a8f850f --- /dev/null +++ b/packages/agent-providers/groq/src/index.ts @@ -0,0 +1 @@ +export * from "./provider"; diff --git a/packages/agent-providers/groq/src/provider.test.ts b/packages/agent-providers/groq/src/provider.test.ts new file mode 100644 index 00000000..71a1058d --- /dev/null +++ b/packages/agent-providers/groq/src/provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { groqProvider } from "./provider.js"; + +describe("groq agent provider", () => { + it("declares metadata and env", () => { + expect(groqProvider.id).toBe("groq"); + expect(groqProvider.displayName).toBe("Groq"); + expect(groqProvider.capabilities.chat).toBe(true); + expect(groqProvider.getRequiredEnv()).toEqual( + expect.arrayContaining([ + { key: "GROQ_API_KEY", required: true }, + { key: "GROQ_BASE_URL", required: false }, + { key: "GROQ_MODEL", required: false }, + ]), + ); + }); + + it("requires GROQ_API_KEY", () => { + expect(() => groqProvider.validateEnv({})).toThrow("Missing GROQ_API_KEY"); + expect(() => groqProvider.validateEnv({ GROQ_API_KEY: "sk-test" })).not.toThrow(); + }); +}); diff --git a/packages/agent-providers/groq/src/provider.ts b/packages/agent-providers/groq/src/provider.ts new file mode 100644 index 00000000..9e5de3ea --- /dev/null +++ b/packages/agent-providers/groq/src/provider.ts @@ -0,0 +1,11 @@ +import { createOpenAICompatibleProvider } from "@profullstack/sh1pt-agent-provider-shared"; + +export const groqProvider = createOpenAICompatibleProvider({ + id: "groq", + displayName: "Groq", + apiKeyEnv: "GROQ_API_KEY", + baseUrlEnv: "GROQ_BASE_URL", + modelEnv: "GROQ_MODEL", + defaultBaseURL: "https://api.groq.com/openai/v1", + defaultModel: "llama-3.3-70b-versatile", +}); diff --git a/packages/agent-providers/groq/tsconfig.json b/packages/agent-providers/groq/tsconfig.json new file mode 100644 index 00000000..41d4c37c --- /dev/null +++ b/packages/agent-providers/groq/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/agent-providers/mistral/README.md b/packages/agent-providers/mistral/README.md new file mode 100644 index 00000000..f5370192 --- /dev/null +++ b/packages/agent-providers/mistral/README.md @@ -0,0 +1,12 @@ +# Mistral Agent Provider + +OpenAI-compatible sh1pt agent provider for Mistral AI. + +Required env: + +- `MISTRAL_API_KEY` + +Optional env: + +- `MISTRAL_BASE_URL` defaults to `https://api.mistral.ai/v1` +- `MISTRAL_MODEL` defaults to `mistral-large-latest` diff --git a/packages/agent-providers/mistral/package.json b/packages/agent-providers/mistral/package.json new file mode 100644 index 00000000..da44754d --- /dev/null +++ b/packages/agent-providers/mistral/package.json @@ -0,0 +1,25 @@ +{ + "name": "@profullstack/sh1pt-agent-provider-mistral", + "version": "0.1.15", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@profullstack/sh1pt-agent-provider-shared": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/agent-providers/mistral" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + } +} diff --git a/packages/agent-providers/mistral/src/index.ts b/packages/agent-providers/mistral/src/index.ts new file mode 100644 index 00000000..1a8f850f --- /dev/null +++ b/packages/agent-providers/mistral/src/index.ts @@ -0,0 +1 @@ +export * from "./provider"; diff --git a/packages/agent-providers/mistral/src/provider.test.ts b/packages/agent-providers/mistral/src/provider.test.ts new file mode 100644 index 00000000..7c223187 --- /dev/null +++ b/packages/agent-providers/mistral/src/provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { mistralProvider } from "./provider.js"; + +describe("mistral agent provider", () => { + it("declares metadata and env", () => { + expect(mistralProvider.id).toBe("mistral"); + expect(mistralProvider.displayName).toBe("Mistral"); + expect(mistralProvider.capabilities.chat).toBe(true); + expect(mistralProvider.getRequiredEnv()).toEqual( + expect.arrayContaining([ + { key: "MISTRAL_API_KEY", required: true }, + { key: "MISTRAL_BASE_URL", required: false }, + { key: "MISTRAL_MODEL", required: false }, + ]), + ); + }); + + it("requires MISTRAL_API_KEY", () => { + expect(() => mistralProvider.validateEnv({})).toThrow("Missing MISTRAL_API_KEY"); + expect(() => mistralProvider.validateEnv({ MISTRAL_API_KEY: "sk-test" })).not.toThrow(); + }); +}); diff --git a/packages/agent-providers/mistral/src/provider.ts b/packages/agent-providers/mistral/src/provider.ts new file mode 100644 index 00000000..8ee511ea --- /dev/null +++ b/packages/agent-providers/mistral/src/provider.ts @@ -0,0 +1,11 @@ +import { createOpenAICompatibleProvider } from "@profullstack/sh1pt-agent-provider-shared"; + +export const mistralProvider = createOpenAICompatibleProvider({ + id: "mistral", + displayName: "Mistral", + apiKeyEnv: "MISTRAL_API_KEY", + baseUrlEnv: "MISTRAL_BASE_URL", + modelEnv: "MISTRAL_MODEL", + defaultBaseURL: "https://api.mistral.ai/v1", + defaultModel: "mistral-large-latest", +}); diff --git a/packages/agent-providers/mistral/tsconfig.json b/packages/agent-providers/mistral/tsconfig.json new file mode 100644 index 00000000..41d4c37c --- /dev/null +++ b/packages/agent-providers/mistral/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/agent-providers/shared/src/__tests__/openai-compatible.test.ts b/packages/agent-providers/shared/src/__tests__/openai-compatible.test.ts new file mode 100644 index 00000000..e4d9489c --- /dev/null +++ b/packages/agent-providers/shared/src/__tests__/openai-compatible.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { AgentProviderConfigError } from "../errors.js"; +import { createOpenAICompatibleProvider } from "../openai-compatible.js"; + +const config = { + id: "testai", + displayName: "TestAI", + apiKeyEnv: "TESTAI_API_KEY", + baseUrlEnv: "TESTAI_BASE_URL", + modelEnv: "TESTAI_MODEL", + defaultBaseURL: "https://api.test.invalid/v1", + defaultModel: "test-model", +}; + +describe("createOpenAICompatibleProvider", () => { + it("declares provider metadata and env requirements", () => { + const provider = createOpenAICompatibleProvider(config, { env: {} }); + + expect(provider.id).toBe("testai"); + expect(provider.displayName).toBe("TestAI"); + expect(provider.capabilities.chat).toBe(true); + expect(provider.getRequiredEnv()).toEqual( + expect.arrayContaining([ + { key: "TESTAI_API_KEY", required: true }, + { key: "TESTAI_BASE_URL", required: false }, + { key: "TESTAI_MODEL", required: false }, + ]), + ); + }); + + it("validates required API key and base URL", () => { + const provider = createOpenAICompatibleProvider(config, { env: {} }); + + expect(() => provider.validateEnv({})).toThrow("Missing TESTAI_API_KEY"); + expect(() => provider.validateEnv({ TESTAI_API_KEY: "sk", TESTAI_BASE_URL: "not-a-url" })).toThrow( + AgentProviderConfigError, + ); + expect(() => provider.validateEnv({ TESTAI_API_KEY: "sk" })).not.toThrow(); + }); + + it("sends chat completions and returns text", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: "hello" } }] }), + })) as unknown as typeof fetch; + const provider = createOpenAICompatibleProvider(config, { + env: { TESTAI_API_KEY: "sk", TESTAI_MODEL: "custom-model" }, + fetch: fetchMock, + }); + + const result = await provider.chat({ messages: [{ role: "user", content: "hi" }] }); + + expect(result.content).toBe("hello"); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test.invalid/v1/chat/completions", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ Authorization: "Bearer sk" }), + }), + ); + const body = JSON.parse(String((fetchMock as any).mock.calls[0][1].body)); + expect(body.model).toBe("custom-model"); + }); + + it("lists model ids", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ data: [{ id: "b" }, { id: "a" }] }), + })) as unknown as typeof fetch; + const provider = createOpenAICompatibleProvider(config, { + env: { TESTAI_API_KEY: "sk" }, + fetch: fetchMock, + }); + + await expect(provider.listModels()).resolves.toEqual(["a", "b"]); + }); + + it("includes provider error messages for failed requests", async () => { + const fetchMock = vi.fn(async () => ({ + ok: false, + status: 404, + text: async () => JSON.stringify({ error: { message: "model not found" } }), + })) as unknown as typeof fetch; + const provider = createOpenAICompatibleProvider(config, { + env: { TESTAI_API_KEY: "sk" }, + fetch: fetchMock, + }); + + await expect(provider.chat({ messages: [{ role: "user", content: "hi" }] })).rejects.toThrow( + "TestAI chat 404: model not found", + ); + }); +}); diff --git a/packages/agent-providers/shared/src/index.ts b/packages/agent-providers/shared/src/index.ts index 19fce76e..177592a6 100644 --- a/packages/agent-providers/shared/src/index.ts +++ b/packages/agent-providers/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "./types"; export * from "./errors"; export * from "./registry"; +export * from "./openai-compatible"; diff --git a/packages/agent-providers/shared/src/openai-compatible.ts b/packages/agent-providers/shared/src/openai-compatible.ts new file mode 100644 index 00000000..c706c561 --- /dev/null +++ b/packages/agent-providers/shared/src/openai-compatible.ts @@ -0,0 +1,198 @@ +import { + AgentProviderConfigError, + AgentProviderRequestError, +} from "./errors"; +import type { + AgentProviderAdapter, + AgentProviderChatResponse, + AgentProviderEnvRequirement, + AgentProviderMessage, +} from "./types"; + +export interface OpenAICompatibleProviderConfig { + id: string; + displayName: string; + apiKeyEnv: string; + baseUrlEnv: string; + modelEnv: string; + defaultBaseURL: string; + defaultModel: string; + optionalEnv?: string[]; +} + +export interface OpenAICompatibleProviderOptions { + env?: Record; + fetch?: typeof fetch; +} + +interface ChatCompletionResponse { + choices?: Array<{ message?: { content?: string | Array<{ text?: string }> } }>; +} + +interface ModelsResponse { + data?: Array<{ id?: string }>; +} + +export function createOpenAICompatibleProvider( + config: OpenAICompatibleProviderConfig, + options: OpenAICompatibleProviderOptions = {}, +): AgentProviderAdapter { + const env = options.env ?? process.env; + const fetchFn = options.fetch ?? fetch; + + return { + id: config.id, + displayName: config.displayName, + capabilities: { chat: true }, + + getRequiredEnv() { + const envVars: AgentProviderEnvRequirement[] = [ + { key: config.apiKeyEnv, required: true }, + { key: config.baseUrlEnv, required: false }, + { key: config.modelEnv, required: false }, + ]; + for (const key of config.optionalEnv ?? []) { + envVars.push({ key, required: false }); + } + return envVars; + }, + + validateEnv(candidateEnv) { + if (!nonEmpty(candidateEnv[config.apiKeyEnv])) { + throw new AgentProviderConfigError(`Missing ${config.apiKeyEnv}`); + } + resolveBaseURL(config, candidateEnv); + }, + + async listModels() { + const { apiKey, baseURL } = resolveRuntime(config, env); + const res = await fetchFn(`${baseURL}/models`, { + method: "GET", + headers: authHeaders(apiKey), + }); + if (!res.ok) { + throw new AgentProviderRequestError(await formatProviderError(config, "listModels", res)); + } + + const data = (await res.json()) as ModelsResponse; + return (data.data ?? []) + .map((model) => model.id) + .filter((id): id is string => Boolean(id)) + .sort(); + }, + + async chat(req) { + const { apiKey, baseURL, model } = resolveRuntime(config, env); + const res = await fetchFn(`${baseURL}/chat/completions`, { + method: "POST", + headers: { + ...authHeaders(apiKey), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: normalizeMessages(req.messages), + }), + }); + if (!res.ok) { + throw new AgentProviderRequestError(await formatProviderError(config, "chat", res)); + } + + const data = (await res.json()) as ChatCompletionResponse; + const content = parseContent(data); + if (!content) { + throw new AgentProviderRequestError(`${config.displayName} empty response`); + } + return { content }; + }, + + async healthcheck() { + this.validateEnv(env); + return { ok: true, message: "env validated only" }; + }, + }; +} + +function resolveRuntime( + config: OpenAICompatibleProviderConfig, + env: Record, +): { apiKey: string; baseURL: string; model: string } { + const apiKey = nonEmpty(env[config.apiKeyEnv]); + if (!apiKey) { + throw new AgentProviderConfigError(`Missing ${config.apiKeyEnv}`); + } + + return { + apiKey, + baseURL: resolveBaseURL(config, env), + model: nonEmpty(env[config.modelEnv]) ?? config.defaultModel, + }; +} + +function resolveBaseURL( + config: OpenAICompatibleProviderConfig, + env: Record, +): string { + const baseURL = nonEmpty(env[config.baseUrlEnv]) ?? config.defaultBaseURL; + try { + return new URL(baseURL).toString().replace(/\/$/, ""); + } catch { + throw new AgentProviderConfigError(`${config.baseUrlEnv} must be a valid URL`); + } +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function authHeaders(apiKey: string): Record { + return { Authorization: `Bearer ${apiKey}` }; +} + +function normalizeMessages(messages: AgentProviderMessage[]): AgentProviderMessage[] { + if (messages.length === 0) { + throw new AgentProviderConfigError("chat requires at least one message"); + } + return messages; +} + +function parseContent(data: ChatCompletionResponse): AgentProviderChatResponse["content"] { + const content = data.choices?.[0]?.message?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => part.text) + .filter((text): text is string => Boolean(text)) + .join(""); + } + return ""; +} + +async function formatProviderError( + config: OpenAICompatibleProviderConfig, + operation: "chat" | "listModels", + res: Response, +): Promise { + const detail = await readErrorDetail(res); + return `${config.displayName} ${operation} ${res.status}${detail ? `: ${detail}` : ""}`; +} + +async function readErrorDetail(res: Response): Promise { + try { + const text = await res.text(); + if (!text.trim()) return ""; + try { + const parsed = JSON.parse(text) as { + error?: string | { message?: string; type?: string }; + message?: string; + }; + if (typeof parsed.error === "string") return parsed.error; + return parsed.error?.message ?? parsed.message ?? text.trim().slice(0, 500); + } catch { + return text.trim().slice(0, 500); + } + } catch { + return ""; + } +} diff --git a/packages/agent-providers/xai/README.md b/packages/agent-providers/xai/README.md new file mode 100644 index 00000000..b64e568c --- /dev/null +++ b/packages/agent-providers/xai/README.md @@ -0,0 +1,12 @@ +# xAI Agent Provider + +OpenAI-compatible sh1pt agent provider for xAI. + +Required env: + +- `XAI_API_KEY` + +Optional env: + +- `XAI_BASE_URL` defaults to `https://api.x.ai/v1` +- `XAI_MODEL` defaults to `grok-4.3` diff --git a/packages/agent-providers/xai/package.json b/packages/agent-providers/xai/package.json new file mode 100644 index 00000000..5c379b66 --- /dev/null +++ b/packages/agent-providers/xai/package.json @@ -0,0 +1,25 @@ +{ + "name": "@profullstack/sh1pt-agent-provider-xai", + "version": "0.1.15", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@profullstack/sh1pt-agent-provider-shared": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/agent-providers/xai" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + } +} diff --git a/packages/agent-providers/xai/src/index.ts b/packages/agent-providers/xai/src/index.ts new file mode 100644 index 00000000..1a8f850f --- /dev/null +++ b/packages/agent-providers/xai/src/index.ts @@ -0,0 +1 @@ +export * from "./provider"; diff --git a/packages/agent-providers/xai/src/provider.test.ts b/packages/agent-providers/xai/src/provider.test.ts new file mode 100644 index 00000000..55e647f5 --- /dev/null +++ b/packages/agent-providers/xai/src/provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { xaiProvider } from "./provider.js"; + +describe("xai agent provider", () => { + it("declares metadata and env", () => { + expect(xaiProvider.id).toBe("xai"); + expect(xaiProvider.displayName).toBe("xAI"); + expect(xaiProvider.capabilities.chat).toBe(true); + expect(xaiProvider.getRequiredEnv()).toEqual( + expect.arrayContaining([ + { key: "XAI_API_KEY", required: true }, + { key: "XAI_BASE_URL", required: false }, + { key: "XAI_MODEL", required: false }, + ]), + ); + }); + + it("requires XAI_API_KEY", () => { + expect(() => xaiProvider.validateEnv({})).toThrow("Missing XAI_API_KEY"); + expect(() => xaiProvider.validateEnv({ XAI_API_KEY: "sk-test" })).not.toThrow(); + }); +}); diff --git a/packages/agent-providers/xai/src/provider.ts b/packages/agent-providers/xai/src/provider.ts new file mode 100644 index 00000000..a3443b93 --- /dev/null +++ b/packages/agent-providers/xai/src/provider.ts @@ -0,0 +1,11 @@ +import { createOpenAICompatibleProvider } from "@profullstack/sh1pt-agent-provider-shared"; + +export const xaiProvider = createOpenAICompatibleProvider({ + id: "xai", + displayName: "xAI", + apiKeyEnv: "XAI_API_KEY", + baseUrlEnv: "XAI_BASE_URL", + modelEnv: "XAI_MODEL", + defaultBaseURL: "https://api.x.ai/v1", + defaultModel: "grok-4.3", +}); diff --git a/packages/agent-providers/xai/tsconfig.json b/packages/agent-providers/xai/tsconfig.json new file mode 100644 index 00000000..41d4c37c --- /dev/null +++ b/packages/agent-providers/xai/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7d6ecd8..ea4e3280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,30 @@ importers: specifier: workspace:* version: link:../../core + packages/agent-providers/cerebras: + dependencies: + '@profullstack/sh1pt-agent-provider-shared': + specifier: workspace:* + version: link:../shared + + packages/agent-providers/deepseek: + dependencies: + '@profullstack/sh1pt-agent-provider-shared': + specifier: workspace:* + version: link:../shared + + packages/agent-providers/groq: + dependencies: + '@profullstack/sh1pt-agent-provider-shared': + specifier: workspace:* + version: link:../shared + + packages/agent-providers/mistral: + dependencies: + '@profullstack/sh1pt-agent-provider-shared': + specifier: workspace:* + version: link:../shared + packages/agent-providers/opencode: dependencies: '@profullstack/sh1pt-agent-provider-shared': @@ -173,6 +197,12 @@ importers: packages/agent-providers/shared: {} + packages/agent-providers/xai: + dependencies: + '@profullstack/sh1pt-agent-provider-shared': + specifier: workspace:* + version: link:../shared + packages/agents/claude: dependencies: '@profullstack/sh1pt-core':