Repository navigation
feat(providers): add Atlas Cloud provider #8783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof vi.fn>): unknown { | ||
| const init = mockFetch.mock.calls.at(-1)?.[1] as RequestInit | undefined | ||
| return (init?.headers as Record<string, string> | undefined)?.Authorization | ||
| } | ||
|
|
||
| describe('GET /api/providers/atlascloud/models', () => { | ||
| let mockFetch: ReturnType<typeof vi.fn> | ||
|
|
||
| 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') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both new suites assert mock calls, including Context Used: CLAUDE.md (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| 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') | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
|
Comment on lines
+35
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The handler decides workspace access and reads a protected key directly. The repository requires protected reads to enter through an authorized application use case and says route adapters must not decide resource authorization. Move the workspace check and key lookup into that shared use case, then call it from the route. This repository requirement must be satisfied before merging. Context Used: CLAUDE.md (source) |
||
| 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: [] }) | ||
| } | ||
|
Comment on lines
+49
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The route returns an empty catalog whenever neither a saved key nor |
||
|
|
||
| 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: [] }) | ||
| } | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5614,6 +5614,22 @@ export function TogetherIcon(props: SVGProps<SVGSVGElement>) { | |
| ) | ||
| } | ||
|
|
||
| export function AtlasCloudIcon(props: SVGProps<SVGSVGElement>) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new Context Used: Component patterns and structure for React components (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| return ( | ||
| <svg | ||
| {...props} | ||
| fill='currentColor' | ||
| fillRule='evenodd' | ||
| height='1em' | ||
| width='1em' | ||
| viewBox='0 0 24 24' | ||
| xmlns='http://www.w3.org/2000/svg' | ||
| > | ||
| <path d='M20.2,18.01L12,0.47,3.8,18.01l-2.58,5.52c1.62,-1.05,3.39,-1.86,5.26,-2.41,1.76,-0.51,3.61,-0.79,5.52,-0.79,0.98,0,1.95,0.08,2.9,0.22l-1.86,-4.3c-0.53,-0.1,-2.87,-0.1,-4.59,0.3l3.56,-8.28,5.52,12.85c0.01,0,0.02,0.01,0.03,0.01,1.86,0.55,3.62,1.36,5.23,2.4l-2.58,-5.52Z' /> | ||
| </svg> | ||
| ) | ||
| } | ||
|
|
||
| export function BasetenIcon(props: SVGProps<SVGSVGElement>) { | ||
| return ( | ||
| <svg | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs export
AtlasCloudIcon, but nothing in the docs app imports it. The repository forbids exports that nothing imports. Remove this unused copy or connect it to an actual docs consumer. This repository requirement must be satisfied before merging.Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!