From 1c7f5a07e2fcda104c964a5068934bc9b0b16266 Mon Sep 17 00:00:00 2001 From: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:11:42 +0800 Subject: [PATCH] feat(providers): add Atlas Cloud provider Add Atlas Cloud as an OpenAI-compatible provider with BYOK support, dynamic model listing, docs and tests. Rebased onto current main: wired into the new attachment-provider map and usage-consumer icon map, and accounts for the pending turn at the tool-loop cap like the other reseller providers. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- apps/docs/components/icons.tsx | 16 ++ apps/docs/content/docs/platform/costs.mdx | 1 + .../self-hosting/environment-variables.mdx | 1 + .../content/docs/workflows/blocks/agent.mdx | 2 +- apps/sim/app/(landing)/models/utils.ts | 1 + .../providers/atlascloud/models/route.test.ts | 82 +++++++ .../api/providers/atlascloud/models/route.ts | 93 +++++++ .../settings/components/byok/byok.tsx | 9 + apps/sim/components/icons.tsx | 16 ++ .../components/usage-consumers.tsx | 2 + apps/sim/hooks/queries/providers.ts | 6 + apps/sim/lib/api-key/byok.ts | 21 ++ apps/sim/lib/api/contracts/byok-keys.ts | 1 + apps/sim/lib/api/contracts/providers.ts | 32 +++ apps/sim/lib/core/config/env.ts | 1 + apps/sim/providers/atlascloud/index.test.ts | 228 ++++++++++++++++++ apps/sim/providers/atlascloud/index.ts | 75 ++++++ apps/sim/providers/attachments.ts | 4 + apps/sim/providers/history-adapters.ts | 1 + apps/sim/providers/models.ts | 18 ++ .../openai-compat/chat-completions.ts | 2 +- apps/sim/providers/registry.ts | 2 + apps/sim/providers/types.ts | 1 + apps/sim/providers/utils.ts | 1 + apps/sim/stores/providers/store.ts | 1 + apps/sim/stores/providers/types.ts | 1 + apps/sim/tools/types.ts | 1 + 27 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/providers/atlascloud/models/route.test.ts create mode 100644 apps/sim/app/api/providers/atlascloud/models/route.ts create mode 100644 apps/sim/providers/atlascloud/index.test.ts create mode 100644 apps/sim/providers/atlascloud/index.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index a39190f4b78..a3ac23e56d6 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5614,6 +5614,22 @@ export function TogetherIcon(props: SVGProps) { ) } +export function AtlasCloudIcon(props: SVGProps) { + return ( + + + + ) +} + export function BasetenIcon(props: SVGProps) { return ( diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 39e7b48fc62..1b46779953e 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -52,6 +52,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `TOGETHER_API_KEY` | Together AI | | `FIREWORKS_API_KEY` | Fireworks AI | | `BASETEN_API_KEY` | Baseten | +| `ATLASCLOUD_API_KEY` | Atlas Cloud | | `COHERE_API_KEY` | Cohere — required for the Knowledge block reranker | | `OLLAMA_URL` | Ollama (default: `http://localhost:11434`) | | `VERTEX_PROJECT` / `VERTEX_LOCATION` | Google Vertex AI — GCP project ID and region (region defaults to `us-central1`) | diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index 83d38cdb4bb..478ad812171 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -29,7 +29,7 @@ The model that runs the step. Defaults to `claude-sonnet-5-5`. Type or pick any For a custom cloud deployment, enter its provider prefix and model ID: `azure/my-deployment`, `azure-anthropic/my-deployment`, `bedrock/my-inference-profile`, or `vertex/my-gemini-model`. The prefix selects the provider and shows its credential fields even when the ID is absent from the catalog. Bedrock accepts full inference profile ARNs after `bedrock/`; Vertex uses the Gemini API and accepts Google model resource names. The deployment must support the selected provider's API. Custom IDs have no catalog pricing or token limits. -Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing. +Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Atlas Cloud, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing. ### Jev evaluation models diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts index 3761baaa8ed..a42c1a71d7e 100644 --- a/apps/sim/app/(landing)/models/utils.ts +++ b/apps/sim/app/(landing)/models/utils.ts @@ -12,6 +12,7 @@ const PROVIDER_PREFIXES: Record = { fireworks: ['fireworks/'], together: ['together/'], baseten: ['baseten/'], + atlascloud: ['atlascloud/'], 'ollama-cloud': ['ollama-cloud/'], groq: ['groq/'], kie: ['kie/'], diff --git a/apps/sim/app/api/providers/atlascloud/models/route.test.ts b/apps/sim/app/api/providers/atlascloud/models/route.test.ts new file mode 100644 index 00000000000..adc1c327800 --- /dev/null +++ b/apps/sim/app/api/providers/atlascloud/models/route.test.ts @@ -0,0 +1,82 @@ +import { authMockFns, createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { jsonResponse } from '@sim/testing/helpers/http' +import { apiKeyByokMock, apiKeyByokMockFns } from '@sim/testing/mocks/api-key-byok.mock' +import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' +import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/providers/utils', () => providersUtilsMock) + +vi.mock('@/lib/api-key/byok', () => apiKeyByokMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +import { GET } from '@/app/api/providers/atlascloud/models/route' + +const mockGetSession = authMockFns.mockGetSession +const mockGetBYOKKey = apiKeyByokMockFns.mockGetBYOKKey +const { mockFilterBlacklistedModels, mockIsProviderBlacklisted } = providersUtilsMockFns +const { mockGetUserEntityPermissions } = permissionsMockFns + +function setEnvKey(value: string | undefined): void { + setEnv({ ATLASCLOUD_API_KEY: value }) +} + +function authHeaderFromLastFetch(mockFetch: ReturnType): unknown { + const init = mockFetch.mock.calls.at(-1)?.[1] as RequestInit | undefined + return (init?.headers as Record | undefined)?.Authorization +} + +describe('GET /api/providers/atlascloud/models', () => { + let mockFetch: ReturnType + + beforeEach(() => { + mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) + + mockIsProviderBlacklisted.mockReturnValue(false) + mockFilterBlacklistedModels.mockImplementation((models: string[]) => models) + mockGetBYOKKey.mockResolvedValue(null) + mockGetSession.mockResolvedValue(null) + mockGetUserEntityPermissions.mockResolvedValue(null) + setEnvKey(undefined) + }) + + afterAll(() => { + resetEnvMock() + }) + + it('uses the BYOK key when workspaceId, session, and permission are present', async () => { + setEnvKey('env-key') + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-key', isBYOK: true }) + mockFetch.mockResolvedValueOnce(jsonResponse({ data: [{ id: 'model-a' }] })) + + const res = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-1') + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['atlascloud/model-a'] }) + + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'atlascloud') + expect(authHeaderFromLastFetch(mockFetch)).toBe('Bearer byok-key') + }) + + it('falls back to the env key when the user lacks workspace permission', async () => { + setEnvKey('env-key') + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue(null) + mockFetch.mockResolvedValueOnce(jsonResponse({ data: [{ id: 'model-a' }] })) + + const res = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-1') + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['atlascloud/model-a'] }) + expect(mockGetBYOKKey).not.toHaveBeenCalled() + expect(authHeaderFromLastFetch(mockFetch)).toBe('Bearer env-key') + }) +}) diff --git a/apps/sim/app/api/providers/atlascloud/models/route.ts b/apps/sim/app/api/providers/atlascloud/models/route.ts new file mode 100644 index 00000000000..f5afa545cd0 --- /dev/null +++ b/apps/sim/app/api/providers/atlascloud/models/route.ts @@ -0,0 +1,93 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + atlascloudProviderModelsQuerySchema, + atlascloudUpstreamResponseSchema, + providerModelsResponseSchema, +} from '@/lib/api/contracts/providers' +import { validationErrorResponse } from '@/lib/api/server' +import { getBYOKKey } from '@/lib/api-key/byok' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' + +const logger = createLogger('AtlasCloudModelsAPI') + +export const GET = withRouteHandler(async (request: NextRequest) => { + if (isProviderBlacklisted('atlascloud')) { + logger.info('Atlas Cloud provider is blacklisted, returning empty models') + return NextResponse.json({ models: [] }) + } + + let apiKey: string | undefined + + const queryValidation = atlascloudProviderModelsQuerySchema.safeParse({ + workspaceId: request.nextUrl.searchParams.get('workspaceId') ?? undefined, + }) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { workspaceId } = queryValidation.data + if (workspaceId) { + const session = await getSession() + if (session?.user?.id) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission) { + const byokResult = await getBYOKKey(workspaceId, 'atlascloud') + if (byokResult) { + apiKey = byokResult.apiKey + } + } + } + } + + if (!apiKey) { + apiKey = env.ATLASCLOUD_API_KEY + } + + if (!apiKey) { + logger.info('No Atlas Cloud API key available, returning empty models') + return NextResponse.json({ models: [] }) + } + + try { + const response = await fetch('https://inference.atlascloud.co/v1/models', { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + if (!response.ok) { + logger.warn('Failed to fetch Atlas Cloud models', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json({ models: [] }) + } + + const data = atlascloudUpstreamResponseSchema.parse(await response.json()) + + const allModels: string[] = [] + for (const model of data.data ?? []) { + allModels.push(`atlascloud/${model.id}`) + } + + const uniqueModels = Array.from(new Set(allModels)) + const models = filterBlacklistedModels(uniqueModels) + + logger.info('Successfully fetched Atlas Cloud models', { + count: models.length, + filtered: uniqueModels.length - models.length, + }) + + return NextResponse.json(providerModelsResponseSchema.parse({ models })) + } catch (error) { + logger.error('Error fetching Atlas Cloud models', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ models: [] }) + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index bb7299df95d..0bf98e8a201 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -7,6 +7,7 @@ import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { AnthropicIcon, + AtlasCloudIcon, BasetenIcon, BrandfetchIcon, CohereIcon, @@ -168,6 +169,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'LLM calls', placeholder: 'Enter your Nebius Token Factory API key', }, + { + id: 'atlascloud', + name: 'Atlas Cloud', + icon: AtlasCloudIcon, + description: 'LLM calls', + placeholder: 'Enter your Atlas Cloud API key', + }, { id: 'ollama-cloud', name: 'Ollama Cloud', @@ -381,6 +389,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'together', 'baseten', 'nebius', + 'atlascloud', 'ollama-cloud', 'kie', 'falai', diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index a39190f4b78..a3ac23e56d6 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5614,6 +5614,22 @@ export function TogetherIcon(props: SVGProps) { ) } +export function AtlasCloudIcon(props: SVGProps) { + return ( + + + + ) +} + export function BasetenIcon(props: SVGProps) { return ( >> = { anthropic: AnthropicIcon, + atlascloud: AtlasCloudIcon, baseten: BasetenIcon, bedrock: BedrockIcon, cerebras: CerebrasIcon, diff --git a/apps/sim/hooks/queries/providers.ts b/apps/sim/hooks/queries/providers.ts index 0995a410151..a189557f352 100644 --- a/apps/sim/hooks/queries/providers.ts +++ b/apps/sim/hooks/queries/providers.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { queryOptions, type UseQueryResult, useQueries, useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { + getAtlascloudProviderModelsContract, getBaseProviderModelsContract, getBasetenProviderModelsContract, getFireworksProviderModelsContract, @@ -99,6 +100,11 @@ async function requestProviderModels( query: { workspaceId }, signal, }) + case 'atlascloud': + return requestJson(getAtlascloudProviderModelsContract, { + query: { workspaceId }, + signal, + }) } } diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 22255ba5564..a351a1d5268 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -305,6 +305,27 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Nebius ${model}`) } + if (provider === 'atlascloud') { + if (workspaceId) { + const byokResult = await getBYOKKey(workspaceId, 'atlascloud') + if (byokResult) { + logger.info('Using BYOK key for Atlas Cloud', { + model, + workspaceId, + scope: byokResult.scope, + }) + return byokResult + } + } + if (userProvidedKey) { + return { apiKey: userProvidedKey, isBYOK: false } + } + if (env.ATLASCLOUD_API_KEY) { + return { apiKey: env.ATLASCLOUD_API_KEY, isBYOK: false } + } + throw new Error(`API key is required for Atlas Cloud ${model}`) + } + if (provider === 'ollama-cloud') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud') diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index 6a562641921..80d859b3229 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -15,6 +15,7 @@ export const byokProviderIdSchema = z.enum([ 'together', 'baseten', 'nebius', + 'atlascloud', 'ollama-cloud', 'kie', 'falai', diff --git a/apps/sim/lib/api/contracts/providers.ts b/apps/sim/lib/api/contracts/providers.ts index 7dcab18ab31..8dc22bbc733 100644 --- a/apps/sim/lib/api/contracts/providers.ts +++ b/apps/sim/lib/api/contracts/providers.ts @@ -36,6 +36,10 @@ export const basetenProviderModelsQuerySchema = z.object({ workspaceId: z.string().min(1).optional(), }) +export const atlascloudProviderModelsQuerySchema = z.object({ + workspaceId: z.string().min(1).optional(), +}) + export const openRouterUpstreamResponseSchema = z.object({ data: z .array( @@ -130,6 +134,24 @@ export const basetenUpstreamResponseSchema = z.object({ object: z.string().optional(), }) +// Atlas Cloud serves an OpenAI-shaped /v1/models with extra fields +// (context_length, pricing, supported_features) that this route does not need. +export const atlascloudUpstreamResponseSchema = z.object({ + data: z + .array( + z + .object({ + id: z.string(), + object: z.string().optional(), + created: z.number().optional(), + owned_by: z.string().optional(), + }) + .passthrough() + ) + .default([]), + object: z.string().optional(), +}) + // Shared by the local Ollama and Ollama Cloud /api/tags endpoints — same `{ models: [{ name }] }` shape. export const ollamaUpstreamResponseSchema = z.object({ models: z @@ -253,3 +275,13 @@ export const getBasetenProviderModelsContract = defineRouteContract({ schema: providerModelsResponseSchema, }, }) + +export const getAtlascloudProviderModelsContract = defineRouteContract({ + method: 'GET', + path: '/api/providers/atlascloud/models', + query: atlascloudProviderModelsQuerySchema, + response: { + mode: 'json', + schema: providerModelsResponseSchema, + }, +}) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index cf0366b0055..9fb0e60d3e0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -273,6 +273,7 @@ export const env = createEnv({ FIREWORKS_API_KEY_3: z.string().min(1).optional(), // Additional Fireworks API key for load balancing TOGETHER_API_KEY: z.string().optional(), // Optional Together AI API key for model listing and inference BASETEN_API_KEY: z.string().optional(), // Optional Baseten API key for model listing and inference + ATLASCLOUD_API_KEY: z.string().optional(), // Optional Atlas Cloud API key for model listing and inference COHERE_API_KEY: z.string().min(1).optional(), // Cohere API key for reranker (rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5) COHERE_API_KEY_1: z.string().min(1).optional(), // Primary Cohere API key for rotation COHERE_API_KEY_2: z.string().min(1).optional(), // Additional Cohere API key for load balancing diff --git a/apps/sim/providers/atlascloud/index.test.ts b/apps/sim/providers/atlascloud/index.test.ts new file mode 100644 index 00000000000..2cc153204d4 --- /dev/null +++ b/apps/sim/providers/atlascloud/index.test.ts @@ -0,0 +1,228 @@ +import { openaiMock, openaiMockFns } from '@sim/testing/mocks/openai.mock' +import { providersMock } from '@sim/testing/mocks/providers.mock' +import { providersAttachmentsMock } from '@sim/testing/mocks/providers-attachments.mock' +import { providersModelsMock } from '@sim/testing/mocks/providers-models.mock' +import { providersTraceEnrichmentMock } from '@sim/testing/mocks/providers-trace-enrichment.mock' +import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' +import { toolsMock, toolsMockFns } from '@sim/testing/mocks/tools.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' + +vi.mock('openai', () => openaiMock) + +vi.mock('@/providers', () => providersMock) + +vi.mock('@/providers/models', () => providersModelsMock) + +vi.mock('@/providers/attachments', () => providersAttachmentsMock) + +vi.mock('@/providers/trace-enrichment', () => providersTraceEnrichmentMock) + +vi.mock('@/providers/utils', () => providersUtilsMock) + +vi.mock('@/tools', () => toolsMock) + +import { atlascloudProvider } from '@/providers/atlascloud/index' + +const mockCreate = openaiMockFns.mockChatCompletionsCreate +providersMock.MAX_TOOL_ITERATIONS = 5 + +const mockPrepareToolsWithUsageControl = providersUtilsMockFns.mockPrepareToolsWithUsageControl +const mockExecuteTool = toolsMockFns.mockExecuteTool +providersUtilsMockFns.mockPrepareToolExecution.mockReturnValue({ + toolParams: { x: 1 }, + executionParams: { x: 1 }, +}) + +const textResponse = (content: string) => ({ + choices: [{ message: { content, tool_calls: [] } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, +}) + +const toolCallResponse = ( + assistant: { content?: string | null; reasoning_content?: string } = {} +) => ({ + choices: [ + { + message: { + content: assistant.content ?? null, + ...(assistant.reasoning_content !== undefined + ? { reasoning_content: assistant.reasoning_content } + : {}), + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, + ], + }, + }, + ], + usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 }, +}) + +const toolDef = { + id: 'my_tool', + name: 'my_tool', + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, +} + +const callBody = (index: number) => mockCreate.mock.calls[index][0] +const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0] + +describe('atlascloudProvider', () => { + beforeEach(() => { + mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({ + tools, + toolChoice: 'auto', + forcedTools: [], + })) + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) + }) + + const baseRequest = { + model: 'atlascloud/openai/gpt-oss-120b', + systemPrompt: 'You are helpful.', + messages: [{ role: 'user' as const, content: 'Hello' }], + apiKey: 'bt-test-key', + } + + it('returns content and token usage for a simple request', async () => { + mockCreate.mockResolvedValueOnce(textResponse('hi there')) + + const result = await atlascloudProvider.executeRequest(baseRequest) + + expect(result).toMatchObject({ + content: 'hi there', + model: 'openai/gpt-oss-120b', + tokens: { input: 10, output: 5, total: 15 }, + }) + }) + + it('preserves custom model casing after an uppercase provider prefix', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + + await atlascloudProvider.executeRequest({ + ...baseRequest, + model: 'ATLASCLOUD/Org/Custom-Model', + }) + + expect(callBody(0).model).toBe('Org/Custom-Model') + }) + + it('streams directly when there are no tools', async () => { + mockCreate.mockResolvedValueOnce((async function* () {})()) + + const result = await atlascloudProvider.executeRequest({ ...baseRequest, stream: true }) + + expect(lastCallBody()).toMatchObject({ stream: true, stream_options: { include_usage: true } }) + expect(result).toHaveProperty('stream') + expect(result).toHaveProperty('execution') + }) + + it('sends a json_schema response_format with no strict field', async () => { + mockCreate.mockResolvedValueOnce(textResponse('{}')) + + await atlascloudProvider.executeRequest({ + ...baseRequest, + responseFormat: { name: 'my_schema', schema: { type: 'object' }, strict: true }, + }) + + expect(lastCallBody().response_format).toEqual({ + type: 'json_schema', + json_schema: { name: 'my_schema', schema: { type: 'object' } }, + }) + expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict') + }) + + it('defers response_format to a final call when tools are active', async () => { + mockCreate + .mockResolvedValueOnce(textResponse('intermediate')) + .mockResolvedValueOnce(textResponse('{"done":true}')) + + await atlascloudProvider.executeRequest({ + ...baseRequest, + responseFormat: { name: 'my_schema', schema: { type: 'object' } }, + tools: [toolDef], + }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(callBody(0).response_format).toBeUndefined() + expect(callBody(0).tools).toBeDefined() + expect(callBody(1).response_format).toEqual({ + type: 'json_schema', + json_schema: { name: 'my_schema', schema: { type: 'object' } }, + }) + expect(callBody(1).tools).toBeUndefined() + }) + + it('runs the tool loop and threads tool results back into the conversation', async () => { + mockCreate + .mockResolvedValueOnce(toolCallResponse()) + .mockResolvedValueOnce(textResponse('final answer')) + + const result = await atlascloudProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect(mockExecuteTool).toHaveBeenCalledWith('my_tool', { x: 1 }, expect.anything()) + expect(result).toMatchObject({ content: 'final answer' }) + expect((result as { toolCalls?: unknown[] }).toolCalls).toHaveLength(1) + + const followUpMessages = callBody(1).messages + expect(followUpMessages).toContainEqual( + expect.objectContaining({ role: 'assistant', tool_calls: expect.any(Array) }) + ) + expect(followUpMessages).toContainEqual( + expect.objectContaining({ role: 'tool', tool_call_id: 'call_1' }) + ) + }) + + it('replays AtlasCloud assistant content and reasoning_content on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await atlascloudProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) + + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) + + const result = (await atlascloudProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/apps/sim/providers/atlascloud/index.ts b/apps/sim/providers/atlascloud/index.ts new file mode 100644 index 00000000000..58cbdeb87c0 --- /dev/null +++ b/apps/sim/providers/atlascloud/index.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import OpenAI from 'openai' +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import type { StreamingExecution } from '@/executor/types' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + type ChatCompletionPayload, + executeChatCompletionRequest, +} from '@/providers/openai-compat/chat-completions' +import { buildJsonSchemaResponseFormat } from '@/providers/response-format' +import { openAICompatTransport } from '@/providers/transport' +import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' + +const logger = createLogger('AtlasCloudProvider') + +/** Atlas Cloud accepts native JSON schemas. */ +async function applyResponseFormat( + targetPayload: ChatCompletionPayload, + messages: ChatCompletionMessageParam[], + responseFormat: NonNullable, + model: string +): Promise { + logger.info('Using native structured outputs for Atlas Cloud model', { model }) + targetPayload.response_format = buildJsonSchemaResponseFormat(responseFormat, { + includeStrict: false, + }) + return messages +} + +export const atlascloudProvider: ProviderConfig = { + id: 'atlascloud', + name: 'Atlas Cloud', + description: 'One key for DeepSeek, GLM, Kimi, Qwen and MiniMax via Atlas Cloud', + version: '1.0.0', + models: getProviderModels('atlascloud'), + defaultModel: getProviderDefaultModel('atlascloud'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) { + throw new Error('API key is required for Atlas Cloud') + } + + const client = new OpenAI({ + ...openAICompatTransport(), + apiKey: request.apiKey, + baseURL: 'https://api.atlascloud.ai/v1', + }) + + const requestedModel = request.model.replace(/^atlascloud\//i, '') + + logger.info('Preparing Atlas Cloud request', { + model: requestedModel, + hasSystemPrompt: !!request.systemPrompt, + hasMessages: !!request.messages?.length, + hasTools: !!request.tools?.length, + toolCount: request.tools?.length || 0, + hasResponseFormat: !!request.responseFormat, + stream: !!request.stream, + }) + + return executeChatCompletionRequest(request, { + providerId: 'atlascloud', + providerName: 'Atlas Cloud', + client, + requestedModel, + reportedModel: requestedModel, + logger, + applyResponseFormat, + reasoningFields: ['reasoning_content'], + recordPendingUsage: true, + }) + }, +} diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 0f60300f838..9e12714651c 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -36,6 +36,7 @@ export type AttachmentProvider = | 'together' | 'baseten' | 'nebius' + | 'atlascloud' | 'ollama' | 'vllm' | 'litellm' @@ -175,6 +176,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = { together: 'images through image_url message parts on vision models', baseten: 'images through image_url message parts on vision models', nebius: 'images through image_url message parts on vision models', + atlascloud: 'images through image_url message parts on vision models', ollama: 'images through image_url message parts on vision models', vllm: 'images through image_url message parts on multimodal models', litellm: 'images through image_url message parts on multimodal models', @@ -200,6 +202,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'together') return 'together' if (providerId === 'baseten') return 'baseten' if (providerId === 'nebius') return 'nebius' + if (providerId === 'atlascloud') return 'atlascloud' if (providerId === 'ollama' || providerId === 'ollama-cloud') return 'ollama' if (providerId === 'vllm') return 'vllm' if (providerId === 'litellm') return 'litellm' @@ -415,6 +418,7 @@ function isMimeTypeSupportedByProvider( case 'together': case 'baseten': case 'nebius': + case 'atlascloud': case 'ollama': case 'vllm': case 'litellm': diff --git a/apps/sim/providers/history-adapters.ts b/apps/sim/providers/history-adapters.ts index 61a0bde4d15..d193b689d11 100644 --- a/apps/sim/providers/history-adapters.ts +++ b/apps/sim/providers/history-adapters.ts @@ -117,6 +117,7 @@ export const providerHistoryProtocols: Record< together: 'chat-completions', baseten: 'chat-completions', nebius: 'chat-completions', + atlascloud: 'chat-completions', vllm: 'chat-completions', litellm: 'chat-completions', } diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index e384a3848e8..9522c7ff8c7 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -10,6 +10,7 @@ import type React from 'react' import { AnthropicIcon, + AtlasCloudIcon, AzureIcon, BasetenIcon, BedrockIcon, @@ -457,6 +458,23 @@ export const PROVIDER_DEFINITIONS: Record = { contextInformationAvailable: false, models: [], }, + atlascloud: { + id: 'atlascloud', + fileAttachment: { maxBytes: 25 * 1024 * 1024, strategy: 'remote-url' }, + name: 'Atlas Cloud', + description: 'One key for DeepSeek, GLM, Kimi, Qwen and MiniMax via Atlas Cloud', + defaultModel: '', + modelPatterns: [/^atlascloud\//], + icon: AtlasCloudIcon, + color: '#1B1B1F', + isReseller: true, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + }, + contextInformationAvailable: false, + models: [], + }, baseten: { id: 'baseten', fileAttachment: { maxBytes: 25 * 1024 * 1024, strategy: 'remote-url' }, diff --git a/apps/sim/providers/openai-compat/chat-completions.ts b/apps/sim/providers/openai-compat/chat-completions.ts index 993dfb340b5..3adda2e5b9d 100644 --- a/apps/sim/providers/openai-compat/chat-completions.ts +++ b/apps/sim/providers/openai-compat/chat-completions.ts @@ -55,7 +55,7 @@ export type ChatCompletionPayload = ChatCompletionCreateParamsNonStreaming & { } interface ChatCompletionExecutionConfig { - providerId: 'baseten' | 'together' | 'fireworks' | 'openrouter' | 'nebius' + providerId: 'atlascloud' | 'baseten' | 'together' | 'fireworks' | 'openrouter' | 'nebius' providerName: string client: OpenAI requestedModel: string diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index 3a3f2065129..d76db9018a9 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { anthropicProvider } from '@/providers/anthropic' +import { atlascloudProvider } from '@/providers/atlascloud' import { azureAnthropicProvider } from '@/providers/azure-anthropic' import { azureOpenAIProvider } from '@/providers/azure-openai' import { basetenProvider } from '@/providers/baseten' @@ -57,6 +58,7 @@ const providerRegistry: Record = { together: togetherProvider, baseten: basetenProvider, nebius: nebiusProvider, + atlascloud: atlascloudProvider, ollama: ollamaProvider, 'ollama-cloud': ollamaCloudProvider, bedrock: bedrockProvider, diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 7a4a67ff601..bd38beb7e98 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -28,6 +28,7 @@ export type ProviderId = | 'together' | 'baseten' | 'nebius' + | 'atlascloud' | 'vllm' | 'litellm' | 'bedrock' diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1cc8a03d458..c0840435f77 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -164,6 +164,7 @@ export const providers: Record = { together: buildProviderMetadata('together'), baseten: buildProviderMetadata('baseten'), nebius: buildProviderMetadata('nebius'), + atlascloud: buildProviderMetadata('atlascloud'), } export function updateOllamaProviderModels(models: string[]): void { diff --git a/apps/sim/stores/providers/store.ts b/apps/sim/stores/providers/store.ts index d2925f8c3f6..95b81cd2b3d 100644 --- a/apps/sim/stores/providers/store.ts +++ b/apps/sim/stores/providers/store.ts @@ -15,6 +15,7 @@ export const useProvidersStore = create((set, get) => ({ fireworks: { models: [], isLoading: false }, together: { models: [], isLoading: false }, baseten: { models: [], isLoading: false }, + atlascloud: { models: [], isLoading: false }, }, openRouterModelInfo: {}, diff --git a/apps/sim/stores/providers/types.ts b/apps/sim/stores/providers/types.ts index 7fe7f8cdeab..ef4d7696abe 100644 --- a/apps/sim/stores/providers/types.ts +++ b/apps/sim/stores/providers/types.ts @@ -7,6 +7,7 @@ export type ProviderName = | 'fireworks' | 'together' | 'baseten' + | 'atlascloud' | 'base' export interface OpenRouterModelInfo { diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 75da1ace616..a04ca7c89f4 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -19,6 +19,7 @@ export type BYOKProviderId = | 'together' | 'baseten' | 'nebius' + | 'atlascloud' | 'ollama-cloud' | 'kie' | 'falai'